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: `

{{ ui.t('integrations.shoppingList') }}

{{ ui.t('integrations.keepToken') }}

{{ ui.t('integrations.history') }}

{{ ui.t('integrations.externalSpend') }}
{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('integrations.externalCount') }}
{{ summaryCount() }}
@if (configured()) {
{{ ui.t('integrations.summary') }}
{{ summaryText() }}
} @else {
{{ ui.t('integrations.notConfigured') }}
}

{{ ui.t('integrations.lists') }}

@for (item of visibleLists(); track item.id) { } @empty {
{{ ui.t('common.noData') }}
}

{{ ui.t('integrations.importTitle') }}

{{ ui.t('integrations.tagsHint') }}
@if (selectedList()) {
{{ listTitle(selectedList()!) }}
#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}
{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}
} @else {
{{ ui.t('integrations.selectListHint') }}
}

{{ ui.t('integrations.latest') }}

@for (item of latestExpenses(); track $index) { } @empty { }
{{ ui.t('table.title') }} {{ ui.t('table.date') }} {{ ui.t('table.amount') }} {{ ui.t('table.actions') }}
{{ itemTitle(item) }}
{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}
{{ itemDate(item) }} {{ itemAmount(item) | number:'1.2-2' }}
{{ ui.t('common.noData') }}

{{ ui.t('integrations.listExpenses') }}

@for (item of selectedListExpenses(); track $index) { } @empty { }
{{ ui.t('table.title') }} {{ ui.t('table.date') }} {{ ui.t('table.amount') }} {{ ui.t('table.actions') }}
{{ itemTitle(item) }}
{{ ownerName(item) || ui.t('common.none') }}
{{ itemDate(item) }} {{ itemAmount(item) | number:'1.2-2' }}
{{ ui.t('common.noData') }}
` }) 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(null); readonly allLists = signal([]); readonly latestExpenses = signal([]); readonly selectedList = signal(null); readonly selectedListExpenses = signal([]); 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(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(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(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(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; } }