changes
This commit is contained in:
@@ -14,9 +14,8 @@ const monthRange = (period: string) => {
|
||||
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 };
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` };
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -97,7 +96,7 @@ const monthRange = (period: string) => {
|
||||
</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" />
|
||||
<input class="form-control" type="number" min="1" max="300" formControlName="limit" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="btn-list justify-content-md-end">
|
||||
@@ -107,14 +106,23 @@ const monthRange = (period: string) => {
|
||||
</form>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
||||
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
|
||||
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
|
||||
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
||||
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</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>
|
||||
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
|
||||
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
|
||||
</div>
|
||||
<div class="text-end small text-secondary">
|
||||
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
|
||||
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
||||
}
|
||||
@@ -126,18 +134,16 @@ const monthRange = (period: string) => {
|
||||
|
||||
<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">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
|
||||
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
|
||||
</div>
|
||||
<div class="list-group list-group-flush ec-scroll-list">
|
||||
@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>
|
||||
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
||||
<div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
||||
@@ -147,12 +153,12 @@ const monthRange = (period: string) => {
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card overflow-hidden ec-accent-card ec-accent-card-success h-100">
|
||||
<div class="card overflow-hidden ec-accent-card ec-accent-card-success">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
||||
<div class="card-body d-grid gap-3">
|
||||
<div class="alert alert-warning mb-0">
|
||||
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
|
||||
<div>{{ ui.t('integrations.importExplainBody') }}</div>
|
||||
<div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="importForm" class="row g-3">
|
||||
@@ -176,91 +182,60 @@ const monthRange = (period: string) => {
|
||||
<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>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
|
||||
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
|
||||
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
|
||||
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
|
||||
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedList()) {
|
||||
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
||||
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-start mb-2">
|
||||
<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 class="fw-semibold">{{ listTitle(selectedList()) }}</div>
|
||||
<div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || selectedListCount() === 0" (click)="importSelectedList()">
|
||||
{{ ui.t('integrations.importSelectedList') }}
|
||||
</button>
|
||||
<div class="text-end">
|
||||
<div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
|
||||
<div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-vcenter 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></tr></thead>
|
||||
<tbody>
|
||||
@for (item of selectedListExpenses(); track $index) {
|
||||
<tr>
|
||||
<td>{{ itemTitle(item) }}</td>
|
||||
<td>{{ itemDate(item) }}</td>
|
||||
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 {
|
||||
@@ -274,7 +249,6 @@ export class IntegrationsComponent implements OnInit {
|
||||
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[]>([]);
|
||||
|
||||
@@ -289,19 +263,22 @@ export class IntegrationsComponent implements OnInit {
|
||||
|
||||
readonly historyForm = this.fb.nonNullable.group({
|
||||
period: [currentMonth(), Validators.required],
|
||||
limit: [50, [Validators.required, Validators.min(1), Validators.max(200)]]
|
||||
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: ['Shopping list API'],
|
||||
tags: ['shopping-list, external-import']
|
||||
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 summaryText = computed(() => JSON.stringify(this.summary(), null, 2));
|
||||
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);
|
||||
|
||||
@@ -315,7 +292,7 @@ export class IntegrationsComponent implements OnInit {
|
||||
});
|
||||
|
||||
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 } }) => {
|
||||
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));
|
||||
@@ -329,7 +306,7 @@ export class IntegrationsComponent implements OnInit {
|
||||
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 } }) => {
|
||||
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();
|
||||
@@ -352,8 +329,7 @@ export class IntegrationsComponent implements OnInit {
|
||||
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) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.latestExpenses.set([]) });
|
||||
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);
|
||||
@@ -365,7 +341,9 @@ export class IntegrationsComponent implements OnInit {
|
||||
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
|
||||
},
|
||||
error: () => {
|
||||
this.allLists.set([]); this.selectedList.set(null); this.selectedListExpenses.set([]);
|
||||
this.allLists.set([]);
|
||||
this.selectedList.set(null);
|
||||
this.selectedListExpenses.set([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -381,21 +359,27 @@ export class IntegrationsComponent implements OnInit {
|
||||
|
||||
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
|
||||
|
||||
importSelectedList() {
|
||||
const list = this.selectedList();
|
||||
if (!list || this.importForm.invalid) return;
|
||||
importPeriod() {
|
||||
if (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) => { this.toast.success(this.ui.t('integrations.importListSuccess')); this.emitWarnings(response.warnings); },
|
||||
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'))
|
||||
});
|
||||
}
|
||||
|
||||
importItem(item: ShoppingListExpenseItem) {
|
||||
if (this.importForm.invalid) return;
|
||||
importSelectedList() {
|
||||
const list = this.selectedList();
|
||||
if (!list || 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) => { this.toast.success(this.ui.t('integrations.importItemSuccess')); this.emitWarnings(response.warnings); },
|
||||
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'))
|
||||
});
|
||||
}
|
||||
@@ -407,17 +391,12 @@ export class IntegrationsComponent implements OnInit {
|
||||
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) => 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 ?? []; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user