189 lines
9.5 KiB
TypeScript
189 lines
9.5 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 lastDay = new Date(year, month, 0).getDate();
|
|
return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` };
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-integrations',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
|
|
templateUrl: './integrations.component.html'
|
|
})
|
|
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 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: [80, [Validators.required, Validators.min(1), Validators.max(300)]]
|
|
});
|
|
|
|
readonly importForm = this.fb.nonNullable.group({
|
|
categoryId: ['', Validators.required],
|
|
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
|
merchant: ['Zakupy']
|
|
});
|
|
|
|
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 summaryListCount = computed(() => {
|
|
const summary = this.summary();
|
|
const groups = [summary?.lists, summary?.totals, summary?.aggregates].find((value) => Array.isArray(value));
|
|
return Array.isArray(groups) ? groups.length : this.visibleLists().length;
|
|
});
|
|
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) => {
|
|
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) => {
|
|
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) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
|
|
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
|
next: (response) => {
|
|
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); }
|
|
|
|
importPeriod() {
|
|
if (this.importForm.invalid) return;
|
|
const raw = this.importForm.getRawValue();
|
|
this.integration.importPeriod({ period: this.historyForm.controls.period.value, categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || 'Zakupy' }).subscribe({
|
|
next: (response) => {
|
|
this.toast.success(this.ui.t('integrations.importPeriodSuccess'));
|
|
this.emitWarnings(response.warnings);
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
|
});
|
|
}
|
|
|
|
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: ['shopping-list'] }).subscribe({
|
|
next: (response) => {
|
|
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'))
|
|
});
|
|
}
|
|
|
|
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); }
|
|
|
|
private loadListExpenses(item: ShoppingListRef) {
|
|
const limit = this.historyForm.controls.limit.value;
|
|
this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
|
|
}
|
|
|
|
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 ?? []; }
|
|
}
|