356 lines
22 KiB
TypeScript
356 lines
22 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
|
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';
|
|
|
|
const formatLocalDate = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
|
const day = `${date.getDate()}`.padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const today = formatLocalDate(new Date());
|
|
|
|
@Component({
|
|
selector: 'app-expenses',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, 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>
|
|
|
|
<div class="mb-3">
|
|
<nav class="nav nav-pills gap-2">
|
|
<a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
|
|
<a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="row row-cards align-items-start">
|
|
<div class="col-12">
|
|
<div class="card overflow-hidden">
|
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
|
<h3 class="card-title mb-0">{{ 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>
|
|
}
|
|
|
|
<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())" /></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>
|
|
|
|
<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>
|
|
} @empty {
|
|
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
|
}
|
|
</div>
|
|
</div></div>
|
|
|
|
<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 (editingExpenseId() && editingProofs().length) {
|
|
<div>
|
|
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
|
|
<div class="d-grid gap-2">
|
|
@for (proof of editingProofs(); track proof.id) {
|
|
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
|
|
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
|
|
</div>
|
|
}
|
|
</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>
|
|
|
|
<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>
|
|
|
|
@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 ec-modal-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 ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
|
}
|
|
`
|
|
})
|
|
export class ExpensesComponent implements OnInit {
|
|
readonly ui = inject(UiService);
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly categoriesService = inject(CategoriesService);
|
|
private readonly merchantsService = inject(MerchantsService);
|
|
private readonly expensesService = inject(ExpensesService);
|
|
private readonly toast = inject(ToastService);
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly router = inject(Router);
|
|
private readonly sanitizer = inject(DomSanitizer);
|
|
|
|
readonly categories = this.categoriesService.items;
|
|
readonly merchants = this.merchantsService.items;
|
|
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 proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
|
const proof = this.proofPreview();
|
|
if (!proof || !this.isPdf(proof)) return null;
|
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
|
if (!previewUrl) return null;
|
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
|
});
|
|
readonly selectedFiles = signal<File[]>([]);
|
|
readonly imageChangedEvent = signal<Event | null>(null);
|
|
readonly croppedFile = signal<File | null>(null);
|
|
readonly croppedPreview = signal<string | null>(null);
|
|
readonly showCropper = signal(false);
|
|
readonly editingProofs = signal<Proof[]>([]);
|
|
readonly removedProofIds = signal<string[]>([]);
|
|
|
|
readonly expenseForm = this.fb.nonNullable.group({
|
|
title: ['', [Validators.required, Validators.minLength(2)]],
|
|
amount: [0, [Validators.required, Validators.min(0.01)]],
|
|
expenseDate: [today, Validators.required],
|
|
categoryId: ['', Validators.required],
|
|
merchant: [''],
|
|
paymentMethod: [''],
|
|
description: [''],
|
|
status: ['PENDING'],
|
|
tagsText: [''],
|
|
proofType: ['RECEIPT'],
|
|
proofLabel: [''],
|
|
proofNote: [''],
|
|
customFields: this.fb.array([])
|
|
});
|
|
|
|
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.route.queryParamMap.subscribe((params) => {
|
|
const editId = params.get('edit');
|
|
if (editId) this.loadExpenseForEdit(editId);
|
|
else this.cancelEdit(false);
|
|
});
|
|
}
|
|
|
|
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
|
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
|
|
|
private loadExpenseForEdit(id: string) {
|
|
this.expensesService.getById(id).subscribe({
|
|
next: (response) => this.startEdit(response.item),
|
|
error: (error) => {
|
|
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
|
this.cancelEdit();
|
|
}
|
|
});
|
|
}
|
|
|
|
selectMerchant(id: string) {
|
|
this.selectedMerchantId.set(id);
|
|
const merchant = this.merchants().find((item) => item.id === id);
|
|
this.expenseForm.patchValue({ merchant: merchant?.name ?? '' });
|
|
}
|
|
|
|
openMerchantModal() {
|
|
this.merchantForm.reset({ name: '', kind: 'MERCHANT', notes: '' });
|
|
this.merchantModalOpen.set(true);
|
|
}
|
|
|
|
saveMerchant() {
|
|
if (this.merchantForm.invalid) return;
|
|
this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({
|
|
next: (response) => {
|
|
this.toast.success(this.ui.t('merchant.added'));
|
|
this.merchantModalOpen.set(false);
|
|
this.selectedMerchantId.set(response.item.id);
|
|
this.expenseForm.patchValue({ merchant: response.item.name });
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.saveError'))
|
|
});
|
|
}
|
|
|
|
onProofSelected(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const files = Array.from(input.files ?? []);
|
|
this.selectedFiles.set(files);
|
|
this.croppedFile.set(null);
|
|
this.croppedPreview.set(null);
|
|
this.imageChangedEvent.set(event);
|
|
this.showCropper.set(files.length === 1 && files[0].type.startsWith('image/'));
|
|
}
|
|
|
|
onImageCropped(event: ImageCroppedEvent) {
|
|
if (!event.blob) return;
|
|
const file = new File([event.blob], `proof-${Date.now()}.png`, { type: 'image/png' });
|
|
this.croppedFile.set(file);
|
|
this.croppedPreview.set(event.objectUrl ?? null);
|
|
}
|
|
|
|
markProofForRemoval(proof: Proof) {
|
|
this.removedProofIds.update((ids) => Array.from(new Set([...ids, proof.id])));
|
|
this.editingProofs.update((items) => items.filter((item) => item.id !== proof.id));
|
|
}
|
|
|
|
submitExpense(forcedStatus?: Expense['status']) {
|
|
this.submitted.set(true);
|
|
this.expenseForm.markAllAsTouched();
|
|
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);
|
|
|
|
const formData = new FormData();
|
|
formData.set('title', raw.title);
|
|
formData.set('amount', String(raw.amount));
|
|
formData.set('expenseDate', raw.expenseDate);
|
|
formData.set('categoryId', raw.categoryId);
|
|
formData.set('merchant', raw.merchant);
|
|
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);
|
|
formData.set('removeProofIds', JSON.stringify(this.removedProofIds()));
|
|
|
|
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));
|
|
}
|
|
|
|
const request = this.editingExpenseId()
|
|
? this.expensesService.update(this.editingExpenseId()!, formData)
|
|
: this.expensesService.create(formData);
|
|
|
|
request.subscribe({
|
|
next: (response) => {
|
|
this.saving.set(false);
|
|
this.submitted.set(false);
|
|
response.warnings?.forEach((warning) => this.toast.warning(warning));
|
|
const wasEditing = Boolean(this.editingExpenseId());
|
|
this.toast.success(wasEditing ? this.ui.t('expenses.saved') : status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
|
|
this.resetForm();
|
|
if (wasEditing) this.router.navigate(['/expenses/add']);
|
|
},
|
|
error: (error) => {
|
|
this.saving.set(false);
|
|
this.toast.error(error.error?.message ?? (this.editingExpenseId() ? this.ui.t('expenses.saveError') : this.ui.t('expenses.addError')));
|
|
}
|
|
});
|
|
}
|
|
|
|
startEdit(item: Expense) {
|
|
this.editingExpenseId.set(item.id);
|
|
this.editingProofs.set(item.proofs || []);
|
|
this.removedProofIds.set([]);
|
|
this.submitted.set(false);
|
|
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(navigate = true) {
|
|
this.editingExpenseId.set(null);
|
|
this.submitted.set(false);
|
|
this.resetForm();
|
|
if (navigate) this.router.navigate(['/expenses/add']);
|
|
}
|
|
|
|
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.imageChangedEvent.set(null);
|
|
this.showCropper.set(false);
|
|
this.editingProofs.set([]);
|
|
this.removedProofIds.set([]);
|
|
}
|
|
|
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
|
closeMerchantModal() { this.merchantModalOpen.set(false); }
|
|
closeProofPreview() { this.proofPreview.set(null); }
|
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
|
}
|