zmiany
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { MerchantsService } from '../../core/services/merchants.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { Expense, Merchant, Proof } from '../../shared/models';
|
||||
import type { DuplicateGroup, Expense, Merchant, Proof } from '../../shared/models';
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
@@ -24,269 +24,150 @@ const today = formatLocalDate(new Date());
|
||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent],
|
||||
template: `
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2><div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (duplicateGroups().length) {
|
||||
<div class="alert alert-warning">
|
||||
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
|
||||
<div class="d-grid gap-1">@for (group of duplicateGroups().slice(0, 3); track group.source.id) { <div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div> }</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row row-cards align-items-start">
|
||||
<div class="col-xl-7">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
|
||||
@if (editingExpenseId()) {
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-header d-flex justify-content-between align-items-center"><h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>@if (editingExpenseId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }</div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
|
||||
@if (submitted() && expenseForm.invalid) {
|
||||
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
|
||||
}
|
||||
@if (submitted() && expenseForm.invalid) { <div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div> }
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" />
|
||||
@if (expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.title') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" />
|
||||
@if (expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.amount') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="date" formControlName="expenseDate" [class.is-invalid]="expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())" />
|
||||
@if (expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.date') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label>
|
||||
<select class="form-select" formControlName="categoryId" [class.is-invalid]="expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())">
|
||||
<option value="">{{ ui.t('common.select') }}</option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<option [value]="category.id">{{ category.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.category') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.payment') }}</label>
|
||||
<select class="form-select" formControlName="paymentMethod">
|
||||
<option value="">{{ ui.t('expenses.payment.none') }}</option>
|
||||
<option value="CARD">{{ ui.t('expenses.payment.card') }}</option>
|
||||
<option value="CASH">{{ ui.t('expenses.payment.cash') }}</option>
|
||||
<option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option>
|
||||
<option value="BLIK">BLIK</option>
|
||||
<option value="OTHER">{{ ui.t('expenses.payment.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)">
|
||||
<option value="">{{ ui.t('expenses.customEntry') }}</option>
|
||||
@for (item of activeMerchants(); track item.id) {
|
||||
<option [value]="item.id">{{ item.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">{{ ui.t('action.add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label>
|
||||
<input class="form-control" formControlName="merchant" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{{ ui.t('expenses.field.description') }}</label>
|
||||
<textarea class="form-control" rows="3" formControlName="description"></textarea>
|
||||
</div>
|
||||
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
|
||||
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label><input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label><input class="form-control" type="date" formControlName="expenseDate" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.payment') }}</label><select class="form-select" formControlName="paymentMethod"><option value="">{{ ui.t('expenses.payment.none') }}</option><option value="CARD">{{ ui.t('expenses.payment.card') }}</option><option value="CASH">{{ ui.t('expenses.payment.cash') }}</option><option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option><option value="BLIK">BLIK</option><option value="OTHER">{{ ui.t('expenses.payment.other') }}</option></select></div>
|
||||
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label><div class="input-group"><select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)"><option value="">{{ ui.t('expenses.customEntry') }}</option>@for (item of activeMerchants(); track item.id) { <option [value]="item.id">{{ item.name }}</option> }</select><button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">{{ ui.t('action.add') }}</button></div></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="3" formControlName="description"></textarea></div>
|
||||
</div>
|
||||
|
||||
@if (!editingExpenseId()) {
|
||||
<div class="card bg-body-tertiary overflow-hidden">
|
||||
<div class="card-body d-grid gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofType') }}</label>
|
||||
<select class="form-select" formControlName="proofType">
|
||||
<option value="RECEIPT">{{ ui.t('proof.receipt') }}</option>
|
||||
<option value="INVOICE">{{ ui.t('proof.invoice') }}</option>
|
||||
<option value="NOTE">{{ ui.t('proof.note') }}</option>
|
||||
<option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option>
|
||||
<option value="OTHER">{{ ui.t('proof.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label>
|
||||
<input class="form-control" formControlName="proofLabel" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.file') }}</label>
|
||||
<input class="form-control" type="file" accept="image/*,.pdf" (change)="onProofSelected($event)" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label>
|
||||
<textarea class="form-control" rows="2" formControlName="proofNote"></textarea>
|
||||
</div>
|
||||
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
|
||||
<div formArrayName="customFields" class="d-grid gap-2">
|
||||
@for (group of customFields.controls; track $index) {
|
||||
<div [formGroupName]="$index" class="row g-2">
|
||||
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
|
||||
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
|
||||
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
|
||||
</div>
|
||||
|
||||
@if (showCropper()) {
|
||||
<div>
|
||||
<div class="form-label">{{ ui.t('expenses.field.crop') }}</div>
|
||||
<image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (croppedPreview()) {
|
||||
<div>
|
||||
<div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div>
|
||||
<img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
@if (!editingExpenseId()) {
|
||||
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
|
||||
</div>
|
||||
@if (showCropper()) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
|
||||
}
|
||||
@if (croppedPreview()) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
|
||||
}
|
||||
@if (selectedFiles().length) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
|
||||
}
|
||||
</div></div>
|
||||
}
|
||||
|
||||
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="saving()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>{{ saving() ? ui.t('expenses.saving') : (editingExpenseId() ? ui.t('action.saveChanges') : ui.t('action.addExpense')) }}</span>
|
||||
</button>
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
|
||||
<button class="btn btn-success" [disabled]="saving()">{{ saving() ? ui.t('expenses.saving') : ui.t('action.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-5">
|
||||
<div class="card sticky-top overflow-hidden" style="top: 1rem;">
|
||||
<div class="card overflow-hidden mb-3">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('expenses.filters') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-2 mb-4">
|
||||
<div class="col-6"><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-6"><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-12">
|
||||
<select class="form-select" formControlName="categoryId">
|
||||
<option value="">{{ ui.t('expenses.allCategories') }}</option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<option [value]="category.id">{{ category.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12"><input class="form-control" formControlName="search" [placeholder]="ui.t('expenses.search')" /></div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary flex-fill">{{ ui.t('action.filter') }}</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-body"><form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-3 align-items-end">
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
|
||||
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
|
||||
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
|
||||
</form></div>
|
||||
</div>
|
||||
|
||||
@if (expenses().length) {
|
||||
<div class="list-group list-group-flush">
|
||||
@for (expense of expenses(); track expense.id) {
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ expense.title }}</div>
|
||||
<div class="small text-secondary">{{ expense.merchant || ui.t('expenses.noMerchant') }} • {{ expense.expenseDate | date:'shortDate' }}</div>
|
||||
<div class="small text-secondary">{{ expense.category.name }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="fw-bold">{{ expense.amount | currency:expense.currency:'symbol':'1.2-2' }}</div>
|
||||
<div class="btn-list justify-content-end mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="startEdit(expense)">{{ ui.t('action.edit') }}</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="removeExpense(expense)">{{ ui.t('action.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (expense.proofs.length) {
|
||||
<div class="btn-list mt-3">
|
||||
@for (proof of expense.proofs; track proof.id) {
|
||||
<button class="btn btn-outline-info btn-sm" type="button" (click)="openProof(proof)">
|
||||
{{ proof.label || proof.originalName || ui.t('expenses.proof') }}
|
||||
</button>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of expenses(); track item.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
|
||||
{{ item.title }}
|
||||
@if (item.possibleDuplicate || item.duplicateStatus) {
|
||||
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
|
||||
}
|
||||
@if (item.recurringSourceId) {
|
||||
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning mb-0">{{ ui.t('expenses.noItems') }}</div>
|
||||
}
|
||||
<div class="text-secondary small">{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
|
||||
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
|
||||
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
|
||||
</td>
|
||||
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
|
||||
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-list justify-content-end flex-wrap">
|
||||
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
|
||||
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
|
||||
}
|
||||
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
|
||||
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
|
||||
}
|
||||
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty { <tr><td colspan="4" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (merchantModalOpen()) {
|
||||
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ ui.t('merchant.new') }}</h5>
|
||||
<button class="btn-close" type="button" (click)="closeMerchantModal()"></button>
|
||||
</div>
|
||||
<form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()">
|
||||
<div class="modal-body">
|
||||
<div class="d-grid gap-3">
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('merchant.name') }}</label>
|
||||
<input class="form-control" formControlName="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('merchant.type') }}</label>
|
||||
<select class="form-select" formControlName="kind">
|
||||
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
|
||||
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
|
||||
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('merchant.notes') }}</label>
|
||||
<textarea class="form-control" rows="3" formControlName="notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button>
|
||||
<button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
|
||||
}
|
||||
|
||||
@if (proofPreview()) {
|
||||
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
|
||||
<button class="btn-close" type="button" (click)="closeProofPreview()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if ((proofPreview()?.mimeType || '').includes('pdf')) {
|
||||
<embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" />
|
||||
} @else {
|
||||
<img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body">@if (isPdf(proofPreview()!)) { <embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" /> } @else { <img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show"></div>
|
||||
}
|
||||
`
|
||||
})
|
||||
@@ -301,13 +182,14 @@ export class ExpensesComponent implements OnInit {
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly merchants = this.merchantsService.items;
|
||||
readonly expenses = signal<Expense[]>([]);
|
||||
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
|
||||
readonly selectedMerchantId = signal('');
|
||||
readonly editingExpenseId = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly submitted = signal(false);
|
||||
readonly merchantModalOpen = signal(false);
|
||||
readonly proofPreview = signal<Proof | null>(null);
|
||||
|
||||
readonly selectedFiles = signal<File[]>([]);
|
||||
readonly imageChangedEvent = signal<Event | null>(null);
|
||||
readonly croppedFile = signal<File | null>(null);
|
||||
readonly croppedPreview = signal<string | null>(null);
|
||||
@@ -321,42 +203,42 @@ export class ExpensesComponent implements OnInit {
|
||||
merchant: [''],
|
||||
paymentMethod: [''],
|
||||
description: [''],
|
||||
status: ['PENDING'],
|
||||
tagsText: [''],
|
||||
proofType: ['RECEIPT'],
|
||||
proofLabel: [''],
|
||||
proofNote: ['']
|
||||
proofNote: [''],
|
||||
customFields: this.fb.array([])
|
||||
});
|
||||
|
||||
readonly filterForm = this.fb.nonNullable.group({
|
||||
startDate: [''],
|
||||
endDate: [''],
|
||||
categoryId: [''],
|
||||
search: ['']
|
||||
});
|
||||
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
|
||||
readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] });
|
||||
|
||||
readonly merchantForm = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
|
||||
notes: ['']
|
||||
});
|
||||
get customFields() { return this.expenseForm.controls.customFields as FormArray; }
|
||||
readonly activeMerchants = computed(() => this.merchants().filter((item) => item.isActive));
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.merchantsService.ensureLoaded(true);
|
||||
this.loadExpenses();
|
||||
this.loadDuplicates();
|
||||
}
|
||||
|
||||
activeMerchants() {
|
||||
return this.merchants().filter((item) => item.isActive);
|
||||
}
|
||||
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
||||
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
||||
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
||||
|
||||
loadExpenses() {
|
||||
this.expensesService.list(this.filterForm.getRawValue()).subscribe({
|
||||
next: (response) => this.expenses.set(response.items)
|
||||
});
|
||||
const raw = this.filterForm.getRawValue();
|
||||
this.expensesService.list({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryId: raw.categoryId || undefined, search: raw.search || undefined, status: raw.status || undefined, tags: raw.tags || undefined, duplicatesOnly: raw.duplicatesOnly || undefined }).subscribe({ next: (response) => this.expenses.set(response.items) });
|
||||
}
|
||||
|
||||
loadDuplicates() {
|
||||
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '' });
|
||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
|
||||
this.loadExpenses();
|
||||
}
|
||||
|
||||
@@ -386,16 +268,12 @@ export class ExpensesComponent implements OnInit {
|
||||
|
||||
onProofSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
this.croppedFile.set(file);
|
||||
const files = Array.from(input.files ?? []);
|
||||
this.selectedFiles.set(files);
|
||||
this.croppedFile.set(null);
|
||||
this.croppedPreview.set(null);
|
||||
this.imageChangedEvent.set(event);
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.showCropper.set(true);
|
||||
} else {
|
||||
this.showCropper.set(false);
|
||||
}
|
||||
this.showCropper.set(files.length === 1 && files[0].type.startsWith('image/'));
|
||||
}
|
||||
|
||||
onImageCropped(event: ImageCroppedEvent) {
|
||||
@@ -405,41 +283,30 @@ export class ExpensesComponent implements OnInit {
|
||||
this.croppedPreview.set(event.objectUrl ?? null);
|
||||
}
|
||||
|
||||
submitExpense() {
|
||||
submitExpense(forcedStatus?: Expense['status']) {
|
||||
this.submitted.set(true);
|
||||
this.expenseForm.markAllAsTouched();
|
||||
this.expenseForm.updateValueAndValidity();
|
||||
|
||||
if (this.expenseForm.invalid) return;
|
||||
|
||||
const raw = this.expenseForm.getRawValue();
|
||||
const customFieldEntries = this.customFields.getRawValue().map((item: { key: string; value: string }) => [item.key, item.value] as [string, string]).filter(([key, value]) => Boolean(key && value));
|
||||
const customFields = Object.fromEntries(customFieldEntries);
|
||||
const tags = raw.tagsText.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
const status = forcedStatus ?? (raw.status as Expense['status']);
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.editingExpenseId()) {
|
||||
this.expensesService
|
||||
.update(this.editingExpenseId()!, {
|
||||
title: raw.title,
|
||||
amount: raw.amount,
|
||||
expenseDate: raw.expenseDate,
|
||||
categoryId: raw.categoryId,
|
||||
merchant: raw.merchant,
|
||||
paymentMethod: raw.paymentMethod as Expense['paymentMethod'],
|
||||
description: raw.description,
|
||||
currency: 'PLN'
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.saved'));
|
||||
this.cancelEdit();
|
||||
this.loadExpenses();
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||
}
|
||||
});
|
||||
this.expensesService.update(this.editingExpenseId()!, { title: raw.title, amount: raw.amount, expenseDate: raw.expenseDate, categoryId: raw.categoryId, merchant: raw.merchant, paymentMethod: raw.paymentMethod as Expense['paymentMethod'], description: raw.description, currency: 'PLN', status, tags, customFields }).subscribe({
|
||||
next: (response) => {
|
||||
this.finishSave(response.warnings);
|
||||
this.toast.success(this.ui.t('expenses.saved'));
|
||||
this.cancelEdit();
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -452,33 +319,25 @@ export class ExpensesComponent implements OnInit {
|
||||
formData.set('paymentMethod', raw.paymentMethod);
|
||||
formData.set('description', raw.description);
|
||||
formData.set('currency', 'PLN');
|
||||
formData.set('status', status);
|
||||
formData.set('tags', JSON.stringify(tags));
|
||||
formData.set('customFields', JSON.stringify(customFields));
|
||||
formData.set('proofType', raw.proofType);
|
||||
formData.set('proofLabel', raw.proofLabel);
|
||||
formData.set('proofNote', raw.proofNote);
|
||||
if (this.croppedFile()) formData.set('proofFile', this.croppedFile()!);
|
||||
|
||||
const selected = this.selectedFiles();
|
||||
if (this.croppedFile()) {
|
||||
formData.append('proofFiles', this.croppedFile()!);
|
||||
selected.slice(1).forEach((file) => formData.append('proofFiles', file));
|
||||
} else {
|
||||
selected.forEach((file) => formData.append('proofFiles', file));
|
||||
}
|
||||
|
||||
this.expensesService.create(formData).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.added'));
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
expenseDate: today,
|
||||
categoryId: '',
|
||||
merchant: '',
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
proofType: 'RECEIPT',
|
||||
proofLabel: '',
|
||||
proofNote: ''
|
||||
});
|
||||
this.selectedMerchantId.set('');
|
||||
this.croppedFile.set(null);
|
||||
this.croppedPreview.set(null);
|
||||
this.showCropper.set(false);
|
||||
this.loadExpenses();
|
||||
next: (response) => {
|
||||
this.finishSave(response.warnings);
|
||||
this.toast.success(status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
@@ -487,35 +346,37 @@ export class ExpensesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private finishSave(warnings?: string[]) {
|
||||
this.saving.set(false);
|
||||
this.submitted.set(false);
|
||||
warnings?.forEach((warning) => this.toast.warning(warning));
|
||||
this.resetForm();
|
||||
this.loadExpenses();
|
||||
this.loadDuplicates();
|
||||
}
|
||||
|
||||
startEdit(item: Expense) {
|
||||
this.editingExpenseId.set(item.id);
|
||||
this.submitted.set(false);
|
||||
this.expenseForm.patchValue({
|
||||
title: item.title,
|
||||
amount: item.amount,
|
||||
expenseDate: item.expenseDate,
|
||||
categoryId: item.category.id,
|
||||
merchant: item.merchant ?? '',
|
||||
paymentMethod: item.paymentMethod ?? '',
|
||||
description: item.description ?? ''
|
||||
});
|
||||
this.customFields.clear();
|
||||
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
||||
this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editingExpenseId.set(null);
|
||||
this.submitted.set(false);
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
expenseDate: today,
|
||||
categoryId: '',
|
||||
merchant: '',
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
proofType: 'RECEIPT',
|
||||
proofLabel: '',
|
||||
proofNote: ''
|
||||
});
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
private resetForm() {
|
||||
this.customFields.clear();
|
||||
this.expenseForm.reset({ title: '', amount: 0, expenseDate: today, categoryId: '', merchant: '', paymentMethod: '', description: '', status: 'PENDING', tagsText: '', proofType: 'RECEIPT', proofLabel: '', proofNote: '', customFields: [] as never[] });
|
||||
this.selectedMerchantId.set('');
|
||||
this.selectedFiles.set([]);
|
||||
this.croppedFile.set(null);
|
||||
this.croppedPreview.set(null);
|
||||
this.showCropper.set(false);
|
||||
}
|
||||
|
||||
removeExpense(item: Expense) {
|
||||
@@ -523,20 +384,43 @@ export class ExpensesComponent implements OnInit {
|
||||
next: () => {
|
||||
this.toast.success(this.ui.t('expenses.deleted'));
|
||||
this.loadExpenses();
|
||||
this.loadDuplicates();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
|
||||
});
|
||||
}
|
||||
|
||||
openProof(proof: Proof) {
|
||||
this.proofPreview.set(proof);
|
||||
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||
this.expensesService.reviewDuplicate(item.id, action).subscribe({
|
||||
next: () => {
|
||||
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
|
||||
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
|
||||
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
|
||||
this.loadExpenses();
|
||||
this.loadDuplicates();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
|
||||
});
|
||||
}
|
||||
|
||||
closeMerchantModal() {
|
||||
this.merchantModalOpen.set(false);
|
||||
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||
closeMerchantModal() { this.merchantModalOpen.set(false); }
|
||||
closeProofPreview() { this.proofPreview.set(null); }
|
||||
isPdf(proof: Proof) { return (proof.mimeType || '').includes('pdf'); }
|
||||
|
||||
statusBadgeClass(status: string) {
|
||||
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
||||
}
|
||||
|
||||
closeProofPreview() {
|
||||
this.proofPreview.set(null);
|
||||
duplicateBadgeClass(item: Expense) {
|
||||
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
|
||||
}
|
||||
|
||||
duplicateLabel(item: Expense) {
|
||||
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
|
||||
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
|
||||
return this.ui.t('expenses.duplicateStatus.open');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user