542 lines
23 KiB
TypeScript
542 lines
23 KiB
TypeScript
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { CategoriesService } from '../../core/services/categories.service';
|
|
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
|
import { ToastService } from '../../core/services/toast.service';
|
|
import { UiService } from '../../core/services/ui.service';
|
|
import type { Category, ShoppingListExpenseItem, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
|
|
|
const currentMonth = () => new Date().toISOString().slice(0, 7);
|
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
const monthRange = (period: string) => {
|
|
const safe = /^\d{4}-\d{2}$/.test(period) ? period : currentMonth();
|
|
const [yearText, monthText] = safe.split('-');
|
|
const year = Number(yearText);
|
|
const month = Number(monthText);
|
|
const nextMonth = month === 12 ? new Date(year + 1, 0, 1) : new Date(year, month, 1);
|
|
const end = new Date(nextMonth.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
return { start: `${safe}-01`, end };
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-integrations',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
|
|
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('integrations.title') }}</h2>
|
|
<div class="text-secondary">{{ ui.t('integrations.subtitle') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row row-cards mb-3">
|
|
<div class="col-lg-5">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.shoppingList') }}</h3></div>
|
|
<div class="card-body">
|
|
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
|
<label class="form-check">
|
|
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
|
<span class="form-check-label">{{ ui.t('integrations.enabled') }}</span>
|
|
</label>
|
|
<div>
|
|
<label class="form-label">{{ ui.t('integrations.baseUrl') }}</label>
|
|
<input class="form-control" formControlName="baseUrl" placeholder="https://host.example.com" />
|
|
</div>
|
|
<div>
|
|
<label class="form-label">{{ ui.t('integrations.apiToken') }}</label>
|
|
<input class="form-control" formControlName="apiToken" type="password" />
|
|
<div class="form-hint">{{ ui.t('integrations.keepToken') }}</div>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">{{ ui.t('integrations.authMode') }}</label>
|
|
<select class="form-select" formControlName="authMode">
|
|
<option value="both">Bearer + X-API-Token</option>
|
|
<option value="bearer">Bearer</option>
|
|
<option value="x-api-token">X-API-Token</option>
|
|
</select>
|
|
</div>
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">{{ ui.t('integrations.ownerId') }}</label>
|
|
<input class="form-control" formControlName="ownerId" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">{{ ui.t('integrations.defaultListId') }}</label>
|
|
<input class="form-control" formControlName="defaultListId" />
|
|
</div>
|
|
</div>
|
|
<div class="btn-list flex-wrap">
|
|
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
|
<button class="btn btn-outline-info" type="button" (click)="test()">{{ ui.t('action.testConnection') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-7">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.history') }}</h3></div>
|
|
<div class="card-body d-grid gap-3">
|
|
<form [formGroup]="historyForm" class="row g-3 align-items-end">
|
|
<div class="col-md-4">
|
|
<label class="form-label">{{ ui.t('integrations.period') }}</label>
|
|
<input class="form-control" type="month" formControlName="period" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
|
|
<input class="form-control" type="number" min="1" max="200" formControlName="limit" />
|
|
</div>
|
|
<div class="col-md-5">
|
|
<div class="btn-list justify-content-md-end">
|
|
<button class="btn btn-primary" type="button" (click)="refresh()">{{ ui.t('action.refresh') }}</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="row row-cards">
|
|
<div class="col-md-6">
|
|
<div class="card overflow-hidden">
|
|
<div class="card-body">
|
|
<div class="text-secondary">{{ ui.t('integrations.externalSpend') }}</div>
|
|
<div class="display-6">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card overflow-hidden">
|
|
<div class="card-body">
|
|
<div class="text-secondary">{{ ui.t('integrations.externalCount') }}</div>
|
|
<div class="display-6">{{ summaryCount() }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
|
@if (configured()) {
|
|
<div class="small text-secondary mb-2">{{ ui.t('integrations.summary') }}</div>
|
|
<pre class="mb-0 small">{{ summaryText() }}</pre>
|
|
} @else {
|
|
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row row-cards mb-3">
|
|
<div class="col-lg-4">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.lists') }}</h3></div>
|
|
<div class="list-group list-group-flush">
|
|
@for (item of visibleLists(); track item.id) {
|
|
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
|
|
<div class="d-flex justify-content-between gap-2 align-items-start">
|
|
<div>
|
|
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
|
<div class="text-secondary small">#{{ item.id }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
|
</div>
|
|
<span class="badge text-bg-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }}</span>
|
|
</div>
|
|
</button>
|
|
} @empty {
|
|
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-8">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
|
<div class="card-body d-grid gap-3">
|
|
<form [formGroup]="importForm" class="row g-3">
|
|
<div class="col-md-5">
|
|
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
|
|
<select class="form-select" formControlName="categoryId">
|
|
<option value="">{{ ui.t('common.select') }}</option>
|
|
@for (item of categories(); track item.id) {
|
|
<option [value]="item.id">{{ item.name }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<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>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">{{ ui.t('table.merchant') }}</label>
|
|
<input class="form-control" formControlName="merchant" />
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">{{ ui.t('integrations.tags') }}</label>
|
|
<input class="form-control" formControlName="tags" />
|
|
<div class="form-hint">{{ ui.t('integrations.tagsHint') }}</div>
|
|
</div>
|
|
</form>
|
|
|
|
@if (selectedList()) {
|
|
<div class="border rounded-3 p-3">
|
|
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
|
<div>
|
|
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div>
|
|
<div class="text-secondary small">#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}</div>
|
|
<div class="text-secondary small">{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
|
</div>
|
|
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || selectedListCount() === 0" (click)="importSelectedList()">
|
|
{{ ui.t('integrations.importSelectedList') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
} @else {
|
|
<div class="alert alert-info mb-0">{{ ui.t('integrations.selectListHint') }}</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row row-cards">
|
|
<div class="col-lg-6">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.latest') }}</h3></div>
|
|
<div class="table-responsive">
|
|
<table class="table table-vcenter card-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ ui.t('table.title') }}</th>
|
|
<th>{{ ui.t('table.date') }}</th>
|
|
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
|
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (item of latestExpenses(); track $index) {
|
|
<tr>
|
|
<td>
|
|
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
|
<div class="text-secondary small">{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}</div>
|
|
</td>
|
|
<td>{{ itemDate(item) }}</td>
|
|
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
|
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
|
</tr>
|
|
} @empty {
|
|
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6">
|
|
<div class="card overflow-hidden h-100">
|
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.listExpenses') }}</h3></div>
|
|
<div class="table-responsive">
|
|
<table class="table table-vcenter card-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ ui.t('table.title') }}</th>
|
|
<th>{{ ui.t('table.date') }}</th>
|
|
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
|
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (item of selectedListExpenses(); track $index) {
|
|
<tr>
|
|
<td>
|
|
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
|
<div class="text-secondary small">{{ ownerName(item) || ui.t('common.none') }}</div>
|
|
</td>
|
|
<td>{{ itemDate(item) }}</td>
|
|
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
|
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
|
</tr>
|
|
} @empty {
|
|
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
})
|
|
export class IntegrationsComponent implements OnInit {
|
|
readonly ui = inject(UiService);
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly integration = inject(ShoppingListIntegrationService);
|
|
private readonly categoriesService = inject(CategoriesService);
|
|
private readonly toast = inject(ToastService);
|
|
|
|
readonly categories = this.categoriesService.items;
|
|
readonly configured = signal(false);
|
|
readonly summary = signal<ShoppingListSummary | null>(null);
|
|
readonly allLists = signal<ShoppingListRef[]>([]);
|
|
readonly latestExpenses = signal<ShoppingListExpenseItem[]>([]);
|
|
readonly selectedList = signal<ShoppingListRef | null>(null);
|
|
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
|
|
|
|
readonly form = this.fb.nonNullable.group({
|
|
enabled: [false],
|
|
baseUrl: ['', Validators.required],
|
|
apiToken: [''],
|
|
authMode: ['both' as 'bearer' | 'x-api-token' | 'both', Validators.required],
|
|
ownerId: [''],
|
|
defaultListId: ['']
|
|
});
|
|
|
|
readonly historyForm = this.fb.nonNullable.group({
|
|
period: [currentMonth(), Validators.required],
|
|
limit: [50, [Validators.required, Validators.min(1), Validators.max(200)]]
|
|
});
|
|
|
|
readonly importForm = this.fb.nonNullable.group({
|
|
categoryId: ['', Validators.required],
|
|
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
|
merchant: ['Shopping list API'],
|
|
tags: ['shopping-list, external-import']
|
|
});
|
|
|
|
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
|
|
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
|
|
readonly summaryText = computed(() => JSON.stringify(this.summary(), null, 2));
|
|
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
|
|
readonly selectedListCount = computed(() => this.selectedListExpenses().length);
|
|
|
|
ngOnInit() {
|
|
this.categoriesService.list().subscribe({
|
|
next: (response: { items: Category[] }) => {
|
|
const first = response.items[0];
|
|
if (first && !this.importForm.controls.categoryId.value) this.importForm.controls.categoryId.setValue(first.id);
|
|
},
|
|
error: () => undefined
|
|
});
|
|
|
|
this.integration.getSettings().subscribe({
|
|
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId: string | null; defaultListId: string | null } }) => {
|
|
const item = response.item;
|
|
this.form.reset({
|
|
enabled: item.enabled,
|
|
baseUrl: item.baseUrl || '',
|
|
apiToken: '',
|
|
authMode: item.authMode,
|
|
ownerId: item.ownerId || '',
|
|
defaultListId: item.defaultListId || ''
|
|
});
|
|
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
|
if (this.configured()) this.refresh();
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
|
});
|
|
}
|
|
|
|
save() {
|
|
if (this.form.invalid) return;
|
|
const raw = this.form.getRawValue();
|
|
this.integration
|
|
.updateSettings({
|
|
enabled: raw.enabled,
|
|
baseUrl: raw.baseUrl || null,
|
|
apiToken: raw.apiToken || undefined,
|
|
authMode: raw.authMode,
|
|
ownerId: raw.ownerId || null,
|
|
defaultListId: raw.defaultListId || null
|
|
})
|
|
.subscribe({
|
|
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => {
|
|
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
|
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
|
if (this.configured()) this.refresh();
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.saveError'))
|
|
});
|
|
}
|
|
|
|
test() {
|
|
this.integration.test().subscribe({
|
|
next: () => this.toast.success(this.ui.t('integrations.testSuccess')),
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.testError'))
|
|
});
|
|
}
|
|
|
|
refresh() {
|
|
if (!this.configured()) return;
|
|
const raw = this.form.getRawValue();
|
|
const history = this.historyForm.getRawValue();
|
|
const range = monthRange(history.period);
|
|
const filters = {
|
|
start_date: range.start,
|
|
end_date: range.end,
|
|
owner_id: raw.ownerId || undefined,
|
|
list_id: raw.defaultListId || undefined
|
|
};
|
|
|
|
this.integration.summary(filters).subscribe({
|
|
next: (response: ShoppingListSummary) => this.summary.set(response),
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
|
});
|
|
|
|
this.integration.latest({ ...filters, limit: history.limit }).subscribe({
|
|
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
|
error: () => this.latestExpenses.set([])
|
|
});
|
|
|
|
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
|
next: (response: { items?: ShoppingListRef[]; data?: ShoppingListRef[] }) => {
|
|
const items = this.pickItems<ShoppingListRef>(response);
|
|
this.allLists.set(items);
|
|
const visible = this.visibleLists(items);
|
|
const currentId = String(this.selectedList()?.id ?? '');
|
|
const nextSelected = visible.find((item) => String(item.id) === currentId) ?? visible[0] ?? null;
|
|
this.selectedList.set(nextSelected);
|
|
if (nextSelected) {
|
|
this.loadListExpenses(nextSelected);
|
|
} else {
|
|
this.selectedListExpenses.set([]);
|
|
}
|
|
},
|
|
error: () => {
|
|
this.allLists.set([]);
|
|
this.selectedList.set(null);
|
|
this.selectedListExpenses.set([]);
|
|
}
|
|
});
|
|
}
|
|
|
|
visibleLists(source?: ShoppingListRef[]) {
|
|
const period = this.historyForm.controls.period.value;
|
|
const items = source ?? this.allLists();
|
|
return items.filter((item) => {
|
|
const createdAt = this.listCreatedAt(item);
|
|
return createdAt ? createdAt.slice(0, 7) === period : true;
|
|
});
|
|
}
|
|
|
|
selectList(item: ShoppingListRef) {
|
|
this.selectedList.set(item);
|
|
this.loadListExpenses(item);
|
|
}
|
|
|
|
importSelectedList() {
|
|
const list = this.selectedList();
|
|
if (!list || this.importForm.invalid) return;
|
|
const raw = this.importForm.getRawValue();
|
|
this.integration
|
|
.importList({
|
|
listId: list.id,
|
|
listTitle: this.listTitle(list),
|
|
listCreatedAt: this.listCreatedAt(list),
|
|
categoryId: raw.categoryId,
|
|
status: raw.status,
|
|
merchant: raw.merchant || this.listTitle(list),
|
|
tags: this.normalizedTags()
|
|
})
|
|
.subscribe({
|
|
next: (response: { item: unknown; warnings?: string[] }) => {
|
|
this.toast.success(this.ui.t('integrations.importListSuccess'));
|
|
this.emitWarnings(response.warnings);
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
|
});
|
|
}
|
|
|
|
importItem(item: ShoppingListExpenseItem) {
|
|
if (this.importForm.invalid) return;
|
|
const raw = this.importForm.getRawValue();
|
|
this.integration
|
|
.importItem({
|
|
expenseId: item.expense_id ?? item.id ?? null,
|
|
listId: item.list?.id ?? this.selectedList()?.id ?? null,
|
|
listTitle: this.listTitle(item.list ?? this.selectedList()),
|
|
categoryId: raw.categoryId,
|
|
status: raw.status,
|
|
title: this.itemTitle(item),
|
|
amount: this.itemAmount(item),
|
|
expenseDate: this.itemDate(item),
|
|
merchant: raw.merchant || this.listTitle(item.list ?? this.selectedList()),
|
|
ownerName: this.ownerName(item),
|
|
tags: this.normalizedTags()
|
|
})
|
|
.subscribe({
|
|
next: (response: { item: unknown; warnings?: string[] }) => {
|
|
this.toast.success(this.ui.t('integrations.importItemSuccess'));
|
|
this.emitWarnings(response.warnings);
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
|
});
|
|
}
|
|
|
|
isSelectedList(item: ShoppingListRef) {
|
|
return String(this.selectedList()?.id ?? '') === String(item.id);
|
|
}
|
|
|
|
listTitle(item?: ShoppingListRef | null) {
|
|
return item?.title || item?.name || (item?.id !== undefined ? String(item.id) : '-');
|
|
}
|
|
|
|
listOwner(item?: ShoppingListRef | null) {
|
|
return item?.owner?.username || item?.owner?.fullName || item?.owner?.name || item?.owner?.email || null;
|
|
}
|
|
|
|
listCreatedAt(item?: ShoppingListRef | null) {
|
|
return item?.created_at || null;
|
|
}
|
|
|
|
itemTitle(item: ShoppingListExpenseItem) {
|
|
return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`;
|
|
}
|
|
|
|
itemDate(item: ShoppingListExpenseItem) {
|
|
return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10);
|
|
}
|
|
|
|
itemAmount(item: ShoppingListExpenseItem) {
|
|
return Number(item.amount ?? item.total ?? 0);
|
|
}
|
|
|
|
ownerName(item: ShoppingListExpenseItem) {
|
|
return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null;
|
|
}
|
|
|
|
private loadListExpenses(item: ShoppingListRef) {
|
|
const limit = this.historyForm.controls.limit.value;
|
|
this.integration.listExpenses(item.id, limit).subscribe({
|
|
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
|
error: () => this.selectedListExpenses.set([])
|
|
});
|
|
}
|
|
|
|
private normalizedTags() {
|
|
return Array.from(
|
|
new Set(
|
|
this.importForm.controls.tags.value
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
}
|
|
|
|
private emitWarnings(warnings?: string[]) {
|
|
(warnings ?? []).forEach((message) => this.toast.warning(message));
|
|
}
|
|
|
|
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) {
|
|
return response.items ?? response.data ?? [];
|
|
}
|
|
}
|