202 lines
8.2 KiB
Svelte
202 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
|
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
|
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 { Skeleton } from '$lib/components/ui/skeleton';
|
|
import * as Table from '$lib/components/ui/table';
|
|
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
|
import { parseDashboardFilters } from '$lib/dashboard-filters';
|
|
import { formatDate, formatMoney, formatValue } from '$lib/format';
|
|
import PageHeader from './PageHeader.svelte';
|
|
import AIInsightPanel from './AIInsightPanel.svelte';
|
|
|
|
const dashboard = useDashboardContext();
|
|
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
|
|
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
|
|
let result = $state<SessionDetailPage | null>(null);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let cursor = $state<string | undefined>();
|
|
let sequence = 0;
|
|
|
|
const statusLabels: Record<SessionStatus, string> = {
|
|
Paid: 'پرداختشده',
|
|
Verified: 'تأییدشده',
|
|
Failed: 'ناموفق',
|
|
Reversed: 'برگشتخورده',
|
|
};
|
|
|
|
function badgeVariant(status: SessionStatus) {
|
|
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) {
|
|
const current = ++sequence;
|
|
if (reset) cursor = undefined;
|
|
loading = true;
|
|
error = '';
|
|
const period = dashboard.periodFor(filters.range, filters.period);
|
|
if (!period) return;
|
|
try {
|
|
const next = await api.details(period.periodId, 'ALL', {
|
|
cursor,
|
|
terminalId: filters.terminal === 'all' ? undefined : filters.terminal,
|
|
status: filters.status === 'all' ? undefined : filters.status,
|
|
minAmountRial: filters.minAmount ?? undefined,
|
|
maxAmountRial: filters.maxAmount ?? undefined,
|
|
limit: 25,
|
|
});
|
|
if (current === sequence) result = next;
|
|
} catch {
|
|
if (current === sequence) error = 'فهرست تراکنشها دریافت نشد.';
|
|
} finally {
|
|
if (current === sequence) loading = false;
|
|
}
|
|
}
|
|
|
|
function detailUrl(sessionId: string) {
|
|
return `/transactions/${encodeURIComponent(sessionId)}${page.url.search}`;
|
|
}
|
|
|
|
async function nextPage() {
|
|
if (!result?.pageInfo.nextCursor) return;
|
|
cursor = result.pageInfo.nextCursor;
|
|
await fetchRows();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
$effect(() => {
|
|
filters.range;
|
|
filters.period;
|
|
filters.terminal;
|
|
filters.status;
|
|
filters.minAmount;
|
|
filters.maxAmount;
|
|
void fetchRows(true);
|
|
});
|
|
</script>
|
|
|
|
<svelte:head><title>تراکنشها | تحلیل زرینپال</title></svelte:head>
|
|
<PageHeader
|
|
title="تراکنشها"
|
|
description="جزئیات و تاریخچه پرداختها با فیلترهای قابل اشتراک"
|
|
icon={ArrowUpDownIcon}
|
|
/>
|
|
|
|
<AIInsightPanel
|
|
module="transactions"
|
|
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
|
|
/>
|
|
|
|
<Card class="rounded-lg shadow-xs">
|
|
<CardContent class="p-0">
|
|
{#if loading}
|
|
<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">
|
|
<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 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 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 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
|
|
><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>
|
|
</Table.Root>
|
|
</div>
|
|
<div class="text-muted-foreground flex items-center justify-between border-t p-4 text-xs">
|
|
<span>{formatValue(result.items.length)} مورد در این صفحه</span><Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!result.pageInfo.hasNextPage}
|
|
onclick={nextPage}>صفحه بعد <ArrowLeftIcon /></Button
|
|
>
|
|
</div>
|
|
{:else}
|
|
<div class="grid min-h-72 place-items-center px-4 text-center">
|
|
<div>
|
|
<ArrowUpDownIcon class="text-muted-foreground mx-auto mb-4 size-9" />
|
|
<h2 class="font-bold">تراکنشی با این فیلتر پیدا نشد</h2>
|
|
<p class="text-muted-foreground mt-2 text-sm">بازه یا فیلتر مبلغ و وضعیت را تغییر دهید.</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</CardContent>
|
|
</Card>
|