feat: enrich dashboard filters and feedback
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user