feat: enrich dashboard filters and feedback

This commit is contained in:
2026-08-20 22:31:41 +03:30
parent 9ce2e6ec83
commit 458ab69a3c
24 changed files with 1895 additions and 340 deletions

View File

@@ -1,19 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import FilterIcon from '@lucide/svelte/icons/list-filter';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { api, type SessionDetailPage, type SessionStatus } from '$lib/api/client';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Table from '$lib/components/ui/table';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters, setDashboardQuery } from '$lib/dashboard-filters';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatDate, formatMoney, formatValue } from '$lib/format';
import PageHeader from './PageHeader.svelte';
@@ -24,8 +22,6 @@
let loading = $state(true);
let error = $state('');
let cursor = $state<string | undefined>();
let minAmount = $state('');
let maxAmount = $state('');
let sequence = 0;
const statusLabels: Record<SessionStatus, string> = {
@@ -36,11 +32,17 @@
};
function badgeVariant(status: SessionStatus) {
return status === 'Verified' || status === 'Paid'
? 'secondary'
: status === 'Failed'
? 'destructive'
: 'outline';
return status === 'Paid' ? 'secondary' : status === 'Failed' ? 'destructive' : 'outline';
}
function badgeClass(status: SessionStatus) {
if (status === 'Verified') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300';
}
if (status === 'Reversed') {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300';
}
return '';
}
async function fetchRows(reset = false) {
@@ -48,7 +50,7 @@
if (reset) cursor = undefined;
loading = true;
error = '';
const period = dashboard.periodFor(filters.range);
const period = dashboard.periodFor(filters.range, filters.period);
if (!period) return;
try {
const next = await api.details(period.periodId, 'ALL', {
@@ -67,18 +69,8 @@
}
}
function updateQuery(updates: Record<string, string | null>) {
const next = setDashboardQuery(page.url, updates);
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
function applyAmount(event: SubmitEvent) {
event.preventDefault();
updateQuery({ minAmount: minAmount || null, maxAmount: maxAmount || null });
}
function openRow(sessionId: string) {
void goto(`/transactions/${encodeURIComponent(sessionId)}${page.url.search}`);
function detailUrl(sessionId: string) {
return `/transactions/${encodeURIComponent(sessionId)}${page.url.search}`;
}
async function nextPage() {
@@ -90,12 +82,11 @@
$effect(() => {
filters.range;
filters.period;
filters.terminal;
filters.status;
filters.minAmount;
filters.maxAmount;
minAmount = filters.minAmount?.toString() ?? '';
maxAmount = filters.maxAmount?.toString() ?? '';
void fetchRows(true);
});
</script>
@@ -109,91 +100,75 @@
<Card class="rounded-lg shadow-xs">
<CardContent class="p-0">
<div class="flex flex-wrap items-end gap-2 border-b p-4">
<div class="me-1 flex items-center gap-2 self-center text-sm font-semibold">
<FilterIcon class="text-primary size-4" /> فیلترها
</div>
<label class="text-muted-foreground text-xs"
>وضعیت
<select
value={filters.status}
onchange={(event) => updateQuery({ status: event.currentTarget.value })}
class="border-input bg-background text-foreground mt-1 block h-9 rounded-md border px-3 text-sm"
>
<option value="all">همه وضعیت‌ها</option><option value="Verified">تأییدشده</option><option
value="Paid">پرداخت‌شده</option
><option value="Failed">ناموفق</option><option value="Reversed">برگشت‌خورده</option>
</select>
</label>
<form class="flex flex-wrap items-end gap-2" onsubmit={applyAmount}>
<label class="text-muted-foreground text-xs"
>حداقل مبلغ (ریال)<Input
class="mt-1 h-9 w-36"
type="number"
min="0"
bind:value={minAmount}
/></label
>
<label class="text-muted-foreground text-xs"
>حداکثر مبلغ (ریال)<Input
class="mt-1 h-9 w-36"
type="number"
min="0"
bind:value={maxAmount}
/></label
>
<Button type="submit" size="sm" class="h-9">اعمال مبلغ</Button>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
type="button"
variant="ghost"
size="sm"
class="h-9"
onclick={() => updateQuery({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</form>
</div>
{#if loading}
<div class="text-muted-foreground grid min-h-72 place-items-center text-sm" role="status">
در حال دریافت تراکنش‌ها…
<div class="overflow-hidden" role="status" aria-label="در حال دریافت تراکنش‌ها">
<div class="grid grid-cols-7 gap-3 border-b px-4 py-3">
{#each Array(7) as _}<Skeleton class="h-4 rounded-sm" />{/each}
</div>
{#each Array(8) as _, index}
<div class="grid min-h-14 grid-cols-7 items-center gap-3 border-b px-4 py-3 last:border-0">
<Skeleton class="h-3.5 w-20 rounded-sm" />
<Skeleton class="h-3.5 w-12 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-8 rounded-sm" />
<Skeleton class="h-7 w-20 rounded-md" />
<Skeleton class="size-8 justify-self-end rounded-md" />
</div>
{/each}
</div>
{:else if error}
<div class="grid min-h-72 place-items-center text-sm" role="alert">{error}</div>
<div class="grid min-h-72 place-items-center text-sm">
<div class="space-y-4 text-center">
<p role="alert">{error}</p>
<Button variant="outline" onclick={() => fetchRows(true)}><RotateCcwIcon /> تلاش دوباره</Button>
</div>
</div>
{:else if result && result.items.length}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header
><Table.Row
><Table.Head>شناسه پرداخت</Table.Head><Table.Head>درگاه</Table.Head><Table.Head>زمان</Table.Head
><Table.Head>مبلغ</Table.Head><Table.Head>تلاش‌ها</Table.Head><Table.Head>وضعیت</Table.Head
><Table.Head class="text-right">شناسه پرداخت</Table.Head><Table.Head class="text-right"
>درگاه</Table.Head
><Table.Head class="text-right">زمان</Table.Head><Table.Head class="text-right">مبلغ</Table.Head
><Table.Head class="text-right">تلاش‌ها</Table.Head><Table.Head class="text-right"
>وضعیت</Table.Head
><Table.Head><span class="sr-only">جزئیات</span></Table.Head></Table.Row
></Table.Header
>
<Table.Body>
{#each result.items as item}
<Table.Row
class="hover:bg-muted/60 cursor-pointer transition-colors"
tabindex={0}
onclick={() => openRow(item.sessionId)}
onkeydown={(event) => {
if (event.key === 'Enter') openRow(item.sessionId);
}}
>
<Table.Cell class="font-mono text-xs" dir="ltr">{item.sessionId}</Table.Cell>
<Table.Cell>{item.terminalId}</Table.Cell>
<Table.Cell class="whitespace-nowrap">{formatDate(item.createdAt)}</Table.Cell>
<Table.Cell class="font-semibold whitespace-nowrap">{formatMoney(item.amountRial)}</Table.Cell
<Table.Row class="hover:bg-muted/60 transition-colors">
<Table.Cell class="text-right font-mono text-xs" dir="ltr"
><a
class="hover:text-primary focus-visible:ring-ring rounded-sm underline-offset-4 hover:underline focus-visible:ring-2"
href={detailUrl(item.sessionId)}>{item.sessionId}</a
></Table.Cell
>
<Table.Cell>{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell
><Badge variant={badgeVariant(item.sessionStatus)} class="rounded-md"
<Table.Cell class="text-right">{item.terminalId}</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">{formatDate(item.createdAt)}</Table.Cell>
<Table.Cell class="text-right font-semibold whitespace-nowrap"
>{formatMoney(item.amountRial)}</Table.Cell
>
<Table.Cell class="text-right">{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell class="text-right"
><Badge
variant={badgeVariant(item.sessionStatus)}
class={`rounded-md ${badgeClass(item.sessionStatus)}`}
>{statusLabels[item.sessionStatus]}</Badge
></Table.Cell
>
<Table.Cell><ArrowLeftIcon class="text-muted-foreground size-4" /></Table.Cell>
<Table.Cell
><Button
variant="ghost"
size="icon"
class="size-11"
href={detailUrl(item.sessionId)}
aria-label={`دیدن جزئیات تراکنش ${item.sessionId}`}
><ArrowLeftIcon class="text-muted-foreground size-4" /></Button
></Table.Cell
>
</Table.Row>
{/each}
</Table.Body>