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

@@ -0,0 +1,173 @@
<script lang="ts">
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import * as Popover from '$lib/components/ui/popover';
import { formatValue } from '$lib/format';
const LIMIT = 10_000_000_000;
const STEP = 100_000;
let {
minAmount,
maxAmount,
onchange,
}: {
minAmount: number | null;
maxAmount: number | null;
onchange: (minAmount: number | null, maxAmount: number | null) => void;
} = $props();
let open = $state(false);
let localMin = $state(0);
let localMax = $state(LIMIT);
const startPercent = $derived((localMin / LIMIT) * 100);
const endPercent = $derived((localMax / LIMIT) * 100);
const hasFilter = $derived(minAmount !== null || maxAmount !== null);
$effect(() => {
localMin = Math.max(0, Math.min(minAmount ?? 0, LIMIT));
localMax = Math.max(localMin, Math.min(maxAmount ?? LIMIT, LIMIT));
});
function updateMin(value: number) {
localMin = Math.min(value, localMax - STEP);
}
function updateMax(value: number) {
localMax = Math.max(value, localMin + STEP);
}
function apply() {
onchange(localMin === 0 ? null : localMin, localMax === LIMIT ? null : localMax);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
aria-label="فیلتر بازه مبلغ"
aria-pressed={hasFilter}
>
<SlidersHorizontalIcon class={hasFilter ? 'text-primary size-4' : 'text-muted-foreground size-4'} />
مبلغ
{#if hasFilter}<span class="bg-primary size-1.5 rounded-full" aria-hidden="true"></span>{/if}
</Popover.Trigger>
<Popover.Content align="end" class="w-[min(22rem,calc(100vw-2rem))] gap-4 p-4" dir="rtl">
<div>
<h3 class="font-semibold">بازه مبلغ تراکنش</h3>
<p class="text-muted-foreground mt-1 text-xs">دو نشانگر را برای تعیین کمینه و بیشینه جابه‌جا کنید.</p>
</div>
<div class="flex items-center justify-between gap-3 text-xs tabular-nums">
<output class="bg-muted rounded-md px-2 py-1">از {formatValue(localMin)} ریال</output>
<output class="bg-muted rounded-md px-2 py-1">تا {formatValue(localMax)} ریال</output>
</div>
<div class="range-shell" style={`--range-start:${startPercent}%; --range-end:${endPercent}%`} dir="rtl">
<div class="range-track" aria-hidden="true"></div>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMin}
aria-label="حداقل مبلغ"
oninput={(event) => updateMin(Number(event.currentTarget.value))}
/>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMax}
aria-label="حداکثر مبلغ"
oninput={(event) => updateMax(Number(event.currentTarget.value))}
/>
</div>
<div class="flex gap-2">
<button
type="button"
class="bg-primary text-primary-foreground min-h-11 flex-1 cursor-pointer rounded-md px-3 text-sm font-semibold sm:min-h-9"
onclick={apply}>اعمال بازه</button
>
<button
type="button"
class="border-input hover:bg-muted min-h-11 cursor-pointer rounded-md border px-3 text-sm sm:min-h-9"
onclick={() => {
localMin = 0;
localMax = LIMIT;
onchange(null, null);
open = false;
}}>پاک‌کردن</button
>
</div>
</Popover.Content>
</Popover.Root>
<style>
.range-shell {
position: relative;
height: 2.75rem;
}
.range-track {
position: absolute;
inset: 1.25rem 0 auto;
height: 0.25rem;
border-radius: 999px;
background: linear-gradient(
to left,
var(--muted) 0 var(--range-start),
var(--primary) var(--range-start) var(--range-end),
var(--muted) var(--range-end) 100%
);
}
.range-input {
pointer-events: none;
position: absolute;
inset: 0;
width: 100%;
appearance: none;
background: transparent;
}
.range-input:first-of-type {
z-index: 3;
}
.range-input:last-of-type {
z-index: 2;
}
.range-input::-webkit-slider-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
appearance: none;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
}
.range-input::-moz-range-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
}
.range-input:focus-visible::-webkit-slider-thumb {
outline: 3px solid color-mix(in srgb, var(--primary) 28%, transparent);
outline-offset: 2px;
}
</style>

View File

@@ -17,13 +17,15 @@
import type { AnalyticsRun } from '$lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatMoney, formatMilliseconds, formatPercent, formatValue } from '$lib/format';
import { describePercentile, formulas } from '$lib/insights';
import { describePercentile, formulas, peerProgressTone, retryOpportunity } from '$lib/insights';
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import KpiCard from './KpiCard.svelte';
import FeeShareDonut from './FeeShareDonut.svelte';
import PageHeader from './PageHeader.svelte';
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
@@ -54,7 +56,7 @@
loading = true;
error = '';
try {
const next = await dashboard.runFor(filters.range, filters.terminal);
const next = await dashboard.runFor(filters.range, filters.terminal, filters.period);
if (sequence === loadSequence) run = next;
} catch {
if (sequence === loadSequence) error = 'گزارش این بازه آماده نشد. لطفاً دوباره تلاش کنید.';
@@ -66,12 +68,40 @@
$effect(() => {
filters.range;
filters.terminal;
filters.period;
void loadRun();
});
function percentile(value: string) {
return Math.max(0, Math.min(100, Number(value)));
}
function peerProgressClass(value: string) {
const tone = peerProgressTone(percentile(value));
if (tone === 'leading') return 'bg-emerald-500';
if (tone === 'strong') return 'bg-sky-500';
if (tone === 'developing') return 'bg-amber-500';
return 'bg-rose-500';
}
function retryOpportunityClass(tone: ReturnType<typeof retryOpportunity>['tone']) {
if (tone === 'excellent') {
return {
card: 'border-emerald-200/70 bg-emerald-50/60 dark:border-emerald-900 dark:bg-emerald-950/15',
icon: 'text-emerald-600 dark:text-emerald-400',
};
}
if (tone === 'positive') {
return {
card: 'border-sky-200/70 bg-sky-50/60 dark:border-sky-900 dark:bg-sky-950/15',
icon: 'text-sky-600 dark:text-sky-400',
};
}
return {
card: 'border-amber-200/80 bg-amber-50/60 dark:border-amber-900 dark:bg-amber-950/15',
icon: 'text-amber-600 dark:text-amber-400',
};
}
</script>
<svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head>
@@ -85,7 +115,10 @@
<Skeleton class="mt-4 h-80 rounded-lg" />
{:else if error}
<Card class="border-destructive/30 rounded-lg"
><CardContent class="p-8 text-center"><p role="alert">{error}</p></CardContent></Card
><CardContent class="space-y-4 p-8 text-center"
><p role="alert">{error}</p>
<Button variant="outline" onclick={loadRun}><RefreshCcwIcon /> تلاش دوباره</Button></CardContent
></Card
>
{:else if run}
{@const overview = run.sections.overview}
@@ -94,7 +127,7 @@
{@const peers = run.sections.peerComparison}
{#if kind === 'overview' && overview.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard
label="پرداخت موفق"
value={formatValue(overview.data.bankPaidSessions.value)}
@@ -162,7 +195,12 @@
</CardContent>
</Card>
{:else if kind === 'payments' && performance.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{@const opportunity = retryOpportunity(
Number(performance.data.retryRecoveryRate.value ?? 0),
performance.data.retriedSessions.value,
)}
{@const opportunityClass = retryOpportunityClass(opportunity.tone)}
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard
label="رسیده به بانک"
value={formatValue(performance.data.reachedBankSessions.value)}
@@ -207,26 +245,26 @@
{/each}
</CardContent>
</Card>
<Card class="rounded-lg border-emerald-200/70 bg-emerald-50/50 shadow-xs dark:bg-emerald-950/10">
<Card class={`rounded-lg shadow-xs ${opportunityClass.card}`}>
<CardContent class="p-6">
<LightbulbIcon class="mb-5 size-7 text-emerald-600" />
<h2 class="font-bold">یک فرصت روشن</h2>
<LightbulbIcon class={`mb-5 size-7 ${opportunityClass.icon}`} />
<h2 class="font-bold">{opportunity.title}</h2>
<p class="text-muted-foreground mt-2 text-sm leading-7">
{formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. جزئیات تراکنش‌های ناموفق می‌تواند
نقاط بهبود مسیر را نشان دهد.
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {opportunity.copy}
</p>
</CardContent>
</Card>
</div>
{:else if kind === 'fees' && fees.state === 'READY'}
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-4 min-[600px]:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
note="مجموع کارمزد ثبت‌شده برای پرداخت‌های این بازه"
icon={HandCoinsIcon}
/>
<FeeShareDonut value={fees.data.paidFeeRate.value} />
<KpiCard
label="مبلغ پرداخت موفق"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
@@ -280,7 +318,7 @@
</div>
<div class="bg-muted mt-5 h-2 overflow-hidden rounded-sm" dir="ltr">
<div
class="bg-primary h-full rounded-sm transition-[width] duration-700"
class={`h-full rounded-sm transition-[width] duration-700 ${peerProgressClass(metric.merchantPerformancePercentile)}`}
style={`width:${percentile(metric.merchantPerformancePercentile)}%`}
></div>
</div>

View File

@@ -4,7 +4,6 @@
import ActivityIcon from '@lucide/svelte/icons/activity';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
import HandCoinsIcon from '@lucide/svelte/icons/hand-coins';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MenuIcon from '@lucide/svelte/icons/menu';
@@ -15,11 +14,11 @@
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
import BrandLogo from '$lib/components/BrandLogo.svelte';
import TerminalSelect from '$lib/components/TerminalSelect.svelte';
import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { setDashboardQuery } from '$lib/dashboard-filters';
import { formatValue } from '$lib/format';
let { children } = $props();
const dashboard = useDashboardContext();
@@ -37,7 +36,7 @@
const url = new URL(page.url);
url.pathname = path;
for (const key of [...url.searchParams.keys()]) {
if (!['range', 'terminal'].includes(key)) url.searchParams.delete(key);
if (!['range', 'period', 'terminal'].includes(key)) url.searchParams.delete(key);
}
return `${url.pathname}${url.search}`;
}
@@ -49,9 +48,9 @@
);
}
function terminalChanged(event: Event) {
function terminalChanged(value: string) {
const next = setDashboardQuery(page.url, {
terminal: (event.currentTarget as HTMLSelectElement).value,
terminal: value,
});
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
@@ -83,19 +82,12 @@
<label class="text-muted-foreground mb-1 block text-[11px]" for="sidebar-terminal-select"
>درگاه فعال گزارش</label
>
<select
<TerminalSelect
id="sidebar-terminal-select"
value={page.url.searchParams.get('terminal') ?? 'all'}
onchange={terminalChanged}
class="border-input bg-background focus:ring-ring/30 h-9 w-full rounded-md border px-2 text-xs outline-none focus:ring-2"
>
<option value="all">همه درگاه‌ها</option>
{#each dashboard.terminals as terminal}
<option value={terminal.terminalId}
>{terminal.displayName} · {formatValue(terminal.sessionCount)}</option
>
{/each}
</select>
terminals={dashboard.terminals}
onValueChange={terminalChanged}
/>
</div>
</Sidebar.Header>
<Sidebar.Content class="px-2 py-3">
@@ -107,11 +99,18 @@
<Sidebar.MenuButton
isActive={isActive(item.href)}
tooltipContent={item.label}
class="data-[active=true]:text-primary h-10 justify-start px-3 text-[13px]"
onclick={() => goto(navUrl(item.href))}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
<item.icon class="size-[19px]" strokeWidth={1.8} />
<span>{item.label}</span>
{#snippet child({ props })}
<a
{...props}
href={navUrl(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<item.icon class="size-[19px]" strokeWidth={1.8} />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
@@ -126,11 +125,18 @@
<Sidebar.MenuButton
isActive={isActive('/methodology')}
tooltipContent="روش محاسبه"
class="data-[active=true]:text-primary h-10 justify-start px-3 text-[13px]"
onclick={() => goto(navUrl('/methodology'))}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
<BookOpenIcon class="size-[19px]" strokeWidth={1.8} />
<span>روش محاسبه</span>
{#snippet child({ props })}
<a
{...props}
href={navUrl('/methodology')}
aria-current={isActive('/methodology') ? 'page' : undefined}
>
<BookOpenIcon class="size-[19px]" strokeWidth={1.8} />
<span>روش محاسبه</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -140,7 +146,7 @@
<Sidebar.Footer class="border-sidebar-border border-t p-2">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent="خروج" class="h-10 px-3" onclick={logout}>
<Sidebar.MenuButton tooltipContent="خروج" class="h-11 px-3" onclick={logout}>
<LogOutIcon class="size-[18px]" />
<span>خروج از حساب</span>
</Sidebar.MenuButton>
@@ -155,27 +161,25 @@
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-2">
<Sidebar.Trigger class="size-9" aria-label="باز و بسته کردن منو"><MenuIcon /></Sidebar.Trigger>
<Sidebar.Trigger class="size-11" aria-label="باز و بسته کردن منو"><MenuIcon /></Sidebar.Trigger>
<BrandLogo />
</div>
<div class="flex items-center gap-1">
<label class="sr-only" for="mobile-terminal-select">درگاه فعال گزارش</label>
<select
<TerminalSelect
id="mobile-terminal-select"
value={page.url.searchParams.get('terminal') ?? 'all'}
onchange={terminalChanged}
class="border-input bg-background h-8 max-w-36 rounded-md border px-2 text-xs md:hidden"
>
<option value="all">همه درگاه‌ها</option>
{#each dashboard.terminals as terminal}
<option value={terminal.terminalId}>{terminal.displayName}</option>
{/each}
</select>
<Button variant="ghost" size="icon" onclick={toggleTheme} aria-label="تغییر پوسته">
terminals={dashboard.terminals}
onValueChange={terminalChanged}
mobile
/>
<Button variant="ghost" size="icon" class="size-11" onclick={toggleTheme} aria-label="تغییر پوسته">
{#if dark}<SunIcon />{:else}<MoonIcon />{/if}
</Button>
<span class="bg-border mx-1 h-6 w-px"></span>
<div class="grid size-8 place-items-center rounded-full bg-zinc-900 text-xs font-bold text-white">
<span class="bg-border mx-1 hidden h-6 w-px sm:block"></span>
<div
class="hidden size-8 place-items-center rounded-full bg-zinc-900 text-xs font-bold text-white sm:grid"
>
{dashboard.session?.user.username.slice(0, 1).toUpperCase()}
</div>
</div>

View File

@@ -30,8 +30,10 @@
background-position: -106px -9px;
}
:global(html.dark) .brand-wordmark {
width: 117px;
height: 34px;
background-position: -15px -84px;
width: 124px;
height: 31px;
background-image: url('/brand/zarinpal-wordmark-dark.png');
background-size: contain;
background-position: center;
}
</style>

View File

@@ -2,8 +2,10 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import {
parseDashboardFilters,
@@ -11,28 +13,109 @@
setDashboardQuery,
type DashboardRange,
} from '$lib/dashboard-filters';
import AmountRangeFilter from './AmountRangeFilter.svelte';
import PeriodPicker from './PeriodPicker.svelte';
const dashboard = useDashboardContext();
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
const isTransactionsPage = $derived(page.url.pathname === '/transactions');
function update(updates: Record<string, string | null>) {
const next = setDashboardQuery(page.url, updates);
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
function selectRange(range: DashboardRange) {
const offeredPeriod = dashboard.periodFor(range);
update({ range, period: offeredPeriod?.periodId ?? null });
}
</script>
<div class="bg-card flex flex-wrap items-center justify-end gap-3 rounded-lg border p-2.5 shadow-xs">
<div class="flex items-center gap-1" aria-label="بازه گزارش">
<div
class="bg-card flex flex-wrap items-center justify-end gap-2 rounded-lg border p-2 shadow-xs sm:gap-3 sm:p-2.5"
>
<div class="flex max-w-full items-center gap-1 overflow-x-auto" aria-label="بازه گزارش">
<CalendarRangeIcon class="text-muted-foreground mx-1 hidden size-4 sm:block" />
{#each Object.entries(rangeLabels) as [range, label]}
<Button
variant={filters.range === range ? 'default' : 'ghost'}
size="sm"
class="h-8 rounded-md px-2.5 text-xs"
class="min-h-11 rounded-md px-2.5 text-xs sm:min-h-9"
aria-pressed={filters.range === range}
onclick={() => update({ range: range as DashboardRange })}>{label}</Button
onclick={() => selectRange(range as DashboardRange)}>{label}</Button
>
{/each}
</div>
<PeriodPicker
periods={dashboard.periods}
range={filters.range}
periodId={filters.period}
onselect={(period) => update({ period })}
/>
{#if isTransactionsPage}
<span class="border-border hidden h-8 border-s sm:block" aria-hidden="true"></span>
<div class="flex max-w-full flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<Select.Root
type="single"
value={filters.status}
onValueChange={(value) => update({ status: value })}
items={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'Verified', label: 'تأییدشده' },
{ value: 'Paid', label: 'پرداخت‌شده' },
{ value: 'Failed', label: 'ناموفق' },
{ value: 'Reversed', label: 'برگشت‌خورده' },
]}
>
<Select.Trigger
id="transaction-status-filter"
aria-label="وضعیت تراکنش"
class="min-h-11 cursor-pointer rounded-md px-3 text-xs font-medium sm:min-h-9"
>
<span data-slot="select-value">
{filters.status === 'Verified'
? 'تأییدشده'
: filters.status === 'Paid'
? 'پرداخت‌شده'
: filters.status === 'Failed'
? 'ناموفق'
: filters.status === 'Reversed'
? 'برگشت‌خورده'
: 'همه وضعیت‌ها'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-40 rounded-lg p-1 text-right">
<Select.Item value="all" label="همه وضعیت‌ها" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Verified" label="تأییدشده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Paid" label="پرداخت‌شده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Failed" label="ناموفق" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Reversed" label="برگشت‌خورده" class="min-h-11 cursor-pointer text-right" />
</Select.Content>
</Select.Root>
<AmountRangeFilter
minAmount={filters.minAmount}
maxAmount={filters.maxAmount}
onchange={(minAmount, maxAmount) =>
update({
minAmount: minAmount === null ? null : String(minAmount),
maxAmount: maxAmount === null ? null : String(maxAmount),
})}
/>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
variant="ghost"
size="sm"
class="min-h-11 cursor-pointer rounded-md px-2.5 text-xs sm:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import CircleHelpIcon from '@lucide/svelte/icons/circle-help';
import { Card, CardContent } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { formatPercent } from '$lib/format';
let { value }: { value: string | null } = $props();
const percent = $derived(value === null ? 0 : Math.max(0, Math.min(100, Number(value))));
</script>
<Card class="rounded-lg shadow-xs">
<CardContent class="flex h-full items-center gap-4 p-4 sm:p-5">
<div
class="relative grid size-24 shrink-0 place-items-center"
role="img"
aria-label={`سهم کارمزد ${formatPercent(value)}`}
>
<svg class="size-full -rotate-90" viewBox="0 0 42 42" aria-hidden="true">
<circle cx="21" cy="21" r="15.9" fill="none" stroke="var(--muted)" stroke-width="4" />
<circle
cx="21"
cy="21"
r="15.9"
fill="none"
stroke="var(--primary)"
stroke-width="4"
stroke-linecap="round"
pathLength="100"
stroke-dasharray={`${percent} ${100 - percent}`}
class="transition-[stroke-dasharray] duration-700 motion-reduce:transition-none"
/>
</svg>
<strong class="absolute text-sm tabular-nums">{formatPercent(value)}</strong>
</div>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<h2 class="text-sm font-semibold">سهم کارمزد از پرداخت موفق</h2>
<Tooltip.Root>
<Tooltip.Trigger
aria-label="روش محاسبه سهم کارمزد"
class="text-muted-foreground hover:text-primary -m-3 inline-flex size-11 items-center justify-center"
><CircleHelpIcon class="size-4" /></Tooltip.Trigger
>
<Tooltip.Content class="max-w-72 text-right leading-6"
>کارمزد ثبت‌شده تقسیم بر مبلغ پرداخت‌های موفق در همین بازه.</Tooltip.Content
>
</Tooltip.Root>
</div>
<p class="text-muted-foreground mt-2 text-xs leading-6">
بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.
</p>
</div>
</CardContent>
</Card>

View File

@@ -6,7 +6,10 @@
</script>
<Tooltip.Root>
<Tooltip.Trigger aria-label="روش محاسبه" class="text-muted-foreground hover:text-primary inline-flex">
<Tooltip.Trigger
aria-label="روش محاسبه"
class="text-muted-foreground hover:text-primary -m-3 inline-flex size-11 items-center justify-center"
>
<CircleHelpIcon class="size-4" strokeWidth={1.8} />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-72 text-right leading-6">{text}</Tooltip.Content>

View File

@@ -25,8 +25,8 @@
</script>
<Card class="group rounded-lg shadow-xs transition-transform duration-200 hover:-translate-y-0.5">
<CardContent class="p-5">
<div class="mb-5 flex items-start justify-between gap-3">
<CardContent class="p-4 sm:p-5">
<div class="mb-3 flex items-start justify-between gap-3 sm:mb-5">
<div class="text-muted-foreground flex items-center gap-1.5 text-sm">
<span>{label}</span>
{#if formula}<FormulaHelp text={formula} />{/if}
@@ -34,6 +34,6 @@
<Icon class={iconClass} strokeWidth={1.8} />
</div>
<p class="text-2xl font-extrabold tracking-tight tabular-nums">{value}</p>
<p class="text-muted-foreground mt-2 text-xs leading-6">{note}</p>
<p class="text-muted-foreground mt-1.5 text-xs leading-5 sm:mt-2 sm:leading-6">{note}</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import type { ReportingPeriod } from '$lib/api/client';
import * as Popover from '$lib/components/ui/popover';
import * as Select from '$lib/components/ui/select';
import type { DashboardRange } from '$lib/dashboard-filters';
import {
buildPersianMonthGrid,
buildPersianWeekMonthGrid,
groupPeriodsByJalaliYear,
periodJalaliDateParts,
periodJalaliYear,
} from '$lib/reporting-period-selection';
const monthNames = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
const seasonNames = ['بهار', 'تابستان', 'پاییز', 'زمستان'];
let {
periods,
range,
periodId,
onselect,
}: {
periods: readonly ReportingPeriod[];
range: DashboardRange;
periodId: string;
onselect: (periodId: string) => void;
} = $props();
let open = $state(false);
let selectedYear = $state(0);
let yearInput = $state('');
let calendarMonth = $state(1);
let hoveredWeek = $state('');
const grouped = $derived(groupPeriodsByJalaliYear(periods, range));
const years = $derived([...grouped.keys()]);
const offered = $derived(grouped.get(selectedYear) ?? []);
const matchingPeriods = $derived(
[...periods]
.filter((period) => period.periodType === periodTypeForRange(range) && period.isComplete)
.sort((left, right) => right.localStartAt.localeCompare(left.localStartAt)),
);
const selectedPeriod = $derived(
matchingPeriods.find((period) => period.periodId === periodId) ?? matchingPeriods[0],
);
const calendarGrid = $derived(
!selectedYear || (range !== 'day' && range !== 'week')
? []
: range === 'day'
? buildPersianMonthGrid(selectedYear, calendarMonth, offered)
: buildPersianWeekMonthGrid(selectedYear, calendarMonth, offered),
);
const namedOptions = $derived(
(range === 'month' ? monthNames : seasonNames).map((label, index) => {
const startMonth = range === 'month' ? index + 1 : index * 3 + 1;
const prefix = range === 'month' ? 'jm' : 'ps';
const expectedId = `${prefix}-${String(selectedYear).padStart(4, '0')}-${String(startMonth).padStart(2, '0')}`;
const period = offered.find((candidate) => candidate.periodId === expectedId);
return {
label,
period,
value: period?.periodId ?? `unavailable-${range}-${selectedYear}-${startMonth}`,
disabled: !period,
};
}),
);
$effect(() => {
const preferred = selectedPeriod ? periodJalaliYear(selectedPeriod) : years[0];
selectedYear = preferred ?? 0;
yearInput = preferred ? String(preferred) : '';
calendarMonth = selectedPeriod ? periodJalaliDateParts(selectedPeriod).month : 1;
});
function periodTypeForRange(value: DashboardRange) {
if (value === 'day') return 'DAY';
if (value === 'week') return 'WEEK';
if (value === 'month') return 'JALALI_MONTH';
return 'PERSIAN_SEASON';
}
function choose(nextPeriodId: string) {
onselect(nextPeriodId);
open = false;
}
function updateYear(value: string) {
const digits = value
.replace(/[۰-۹]/g, (digit) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit)))
.replace(/\D/g, '')
.slice(0, 4);
yearInput = digits;
selectedYear = digits.length === 4 ? Number(digits) : 0;
}
function shortPeriodLabel(period?: ReportingPeriod) {
return period?.jalaliLabelFa.replace(/[۰-۹\d]{4}/g, '').trim() ?? '';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 max-w-64 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
aria-label="انتخاب تاریخ دقیق گزارش"
>
<CalendarDaysIcon class="text-primary size-4 shrink-0" />
<span class="truncate">{selectedPeriod?.jalaliLabelFa ?? 'انتخاب تاریخ'}</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-[min(24rem,calc(100vw-2rem))] gap-4 p-4" dir="rtl">
<div>
<h3 class="font-semibold">انتخاب بازه دقیق</h3>
<p class="text-muted-foreground mt-1 text-xs">فقط بازه‌هایی که داده کامل دارند قابل انتخاب‌اند.</p>
</div>
<div class={range === 'day' || range === 'week' ? 'grid grid-cols-2 gap-2' : ''}>
<label class="grid gap-1.5 text-xs font-medium" for="reporting-year">
سال گزارش
<input
id="reporting-year"
class="border-input bg-background focus-visible:ring-ring min-h-11 rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
type="text"
inputmode="numeric"
maxlength="4"
pattern="[0-9۰-۹]{4}"
placeholder="۱۴۰۵"
value={yearInput}
oninput={(event) => updateYear(event.currentTarget.value)}
/>
</label>
{#if range === 'day' || range === 'week'}
<label class="grid gap-1.5 text-xs font-medium" for="reporting-month">
ماه
<select
id="reporting-month"
class="border-input bg-background focus-visible:ring-ring min-h-11 cursor-pointer rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
value={calendarMonth}
onchange={(event) => (calendarMonth = Number(event.currentTarget.value))}
>
{#each monthNames as month, index}<option value={index + 1}>{month}</option>{/each}
</select>
</label>
{/if}
</div>
{#if !offered.length}
<p class="bg-muted text-muted-foreground rounded-md p-4 text-center text-xs" role="status">
برای این سال بازه کامل دیگری موجود نیست.
</p>
{:else if range === 'month' || range === 'season'}
<div class="grid gap-1.5 text-xs font-medium">
<span>{range === 'month' ? 'ماه' : 'فصل'}</span>
<Select.Root
type="single"
value={offered.some((period) => period.periodId === selectedPeriod?.periodId)
? selectedPeriod?.periodId
: ''}
onValueChange={(value) => {
if (namedOptions.some((option) => option.period?.periodId === value)) choose(value);
}}
items={namedOptions.map((option) => ({
value: option.value,
label: option.label,
disabled: option.disabled,
}))}
>
<Select.Trigger class="min-h-11 w-full cursor-pointer rounded-md px-3 text-sm sm:min-h-9">
<span data-slot="select-value">
{offered.find((period) => period.periodId === selectedPeriod?.periodId)
? shortPeriodLabel(selectedPeriod)
: range === 'month'
? 'انتخاب ماه'
: 'انتخاب فصل'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-48 rounded-lg p-1 text-right">
{#each namedOptions as option}
<Select.Item
value={option.value}
label={option.label}
disabled={option.disabled}
class="min-h-11 cursor-pointer text-right"
/>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else if range === 'day' || range === 'week'}
<div>
<div class="text-muted-foreground mb-2 grid grid-cols-7 text-center text-[0.7rem]" aria-hidden="true">
{#each ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as weekday}<span>{weekday}</span>{/each}
</div>
<div
class="grid grid-cols-7 gap-1"
role="grid"
aria-label={range === 'day' ? 'تقویم روزهای قابل گزارش' : 'تقویم هفته‌های قابل گزارش'}
>
{#each calendarGrid as day}
<button
type="button"
role="gridcell"
disabled={!day.periodId}
aria-label={day.periodId
? range === 'day'
? `انتخاب روز ${day.label}`
: `انتخاب هفته شامل روز ${day.label}`
: `روز ${day.label}، بدون داده کامل`}
aria-selected={day.periodId === selectedPeriod?.periodId}
class:bg-primary={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)}
class:text-primary-foreground={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)}
class:opacity-25={!day.inCurrentMonth || !day.periodId}
class="hover:bg-primary/10 focus-visible:ring-ring aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed"
onmouseenter={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}
onmouseleave={() => (hoveredWeek = '')}
onfocus={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}
onblur={() => (hoveredWeek = '')}
onclick={() => day.periodId && choose(day.periodId)}>{day.label}</button
>
{/each}
</div>
</div>
{/if}
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
import type { Terminal } from '$lib/api/client';
import * as Select from '$lib/components/ui/select';
import { formatValue } from '$lib/format';
let {
id,
value,
terminals,
mobile = false,
onValueChange,
}: {
id: string;
value: string;
terminals: Terminal[];
mobile?: boolean;
onValueChange: (value: string) => void;
} = $props();
const selectedLabel = $derived(
value === 'all'
? 'همه درگاه‌ها'
: (terminals.find((terminal) => terminal.terminalId === value)?.displayName ?? 'همه درگاه‌ها'),
);
const items = $derived([
{ value: 'all', label: 'همه درگاه‌ها' },
...terminals.map((terminal) => ({ value: terminal.terminalId, label: terminal.displayName })),
]);
</script>
<Select.Root type="single" {value} {items} {onValueChange}>
<Select.Trigger
{id}
aria-label="درگاه فعال گزارش"
class={mobile
? 'h-11 max-w-36 min-w-0 rounded-md px-2 text-xs md:hidden'
: 'h-10 w-full rounded-md px-2.5 text-xs'}
>
<span data-slot="select-value" class="min-w-0 flex-1 truncate text-start">
{#if !mobile}<WalletCardsIcon class="text-primary size-4" />{/if}
{selectedLabel}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-48 rounded-lg p-1 text-right">
<Select.Item value="all" label="همه درگاه‌ها" class="min-h-11 cursor-pointer text-right" />
{#each terminals as terminal}
<Select.Item value={terminal.terminalId} class="min-h-11 cursor-pointer text-right">
<span class="flex w-full items-center justify-between gap-4">
<span>{terminal.displayName}</span>
<span class="text-muted-foreground text-[11px]" dir="rtl"
>{formatValue(terminal.sessionCount)} تراکنش</span
>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -14,6 +14,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import { formatDate, formatMoney, formatMilliseconds } from '$lib/format';
let detail = $state<SessionDetail | null>(null);
@@ -47,6 +48,14 @@
function back() {
void goto(`/transactions${page.url.search}`);
}
function statusClass(status: keyof typeof statusLabels) {
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 === 'Paid') return 'bg-muted text-muted-foreground';
return '';
}
</script>
<svelte:head><title>جزئیات تراکنش | تحلیل زرین‌پال</title></svelte:head>
@@ -60,7 +69,13 @@
</div>
{#if loading}
<div class="text-muted-foreground grid min-h-80 place-items-center text-sm">در حال دریافت جزئیات…</div>
<div class="space-y-4" role="status" aria-label="در حال دریافت جزئیات تراکنش">
<Skeleton class="h-24 rounded-lg" />
<div class="grid gap-4 lg:grid-cols-[1.25fr_.75fr]">
<Skeleton class="h-80 rounded-lg" />
<Skeleton class="h-80 rounded-lg" />
</div>
</div>
{:else if error || !detail}
<Card class="rounded-lg"><CardContent class="p-10 text-center" role="alert">{error}</CardContent></Card>
{:else}
@@ -73,8 +88,9 @@
<p class="text-muted-foreground mt-1 font-mono text-xs" dir="ltr">{detail.sessionId}</p>
</div>
</div>
<Badge variant={detail.sessionStatus === 'Failed' ? 'destructive' : 'secondary'} class="rounded-md"
>{statusLabels[detail.sessionStatus]}</Badge
<Badge
variant={detail.sessionStatus === 'Failed' ? 'destructive' : 'outline'}
class={`rounded-md ${statusClass(detail.sessionStatus)}`}>{statusLabels[detail.sessionStatus]}</Badge
>
</CardContent>
</Card>
@@ -116,7 +132,7 @@
<div class="flex items-center justify-between gap-2">
<strong class="text-sm">تلاش {attempt.sequence}</strong><Badge
variant="outline"
class="rounded-md">{statusLabels[attempt.status]}</Badge
class={`rounded-md ${statusClass(attempt.status)}`}>{statusLabels[attempt.status]}</Badge
>
</div>
{#if attempt.createdAt}<p class="text-muted-foreground mt-1 text-xs">

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>

View File

@@ -38,12 +38,13 @@ export class DashboardContext {
}
}
periodFor(range: DashboardRange): ReportingPeriod | undefined {
return this.periods.find((period) => period.periodType === rangePeriodTypes[range]);
periodFor(range: DashboardRange, periodId = ''): ReportingPeriod | undefined {
const matching = this.periods.filter((period) => period.periodType === rangePeriodTypes[range]);
return matching.find((period) => period.periodId === periodId) ?? matching[0];
}
async runFor(range: DashboardRange, terminal: string): Promise<AnalyticsRun> {
const period = this.periodFor(range);
async runFor(range: DashboardRange, terminal: string, periodId = ''): Promise<AnalyticsRun> {
const period = this.periodFor(range, periodId);
if (!this.session || !period) throw new Error('Dashboard context is not ready');
return api.startRun(period.periodId, this.session.csrfToken, terminal === 'all' ? undefined : terminal);
}

View File

@@ -10,6 +10,7 @@ describe('dashboard filters', () => {
expect(parseDashboardFilters(query, terminals)).toEqual({
range: 'month',
period: '',
terminal: 'all',
status: 'all',
minAmount: null,
@@ -29,6 +30,34 @@ describe('dashboard filters', () => {
},
);
it('keeps an offered reporting period in the copied URL without dropping other filters', () => {
const filters = parseDashboardFilters(
new URLSearchParams('range=month&period=jm-1405-05&terminal=T83&status=Verified'),
terminals,
);
expect(filters.period).toBe('jm-1405-05');
const next = setDashboardQuery(
new URL('https://example.test/transactions?range=month&terminal=T83&status=Verified'),
{ period: filters.period },
);
expect(next.searchParams.get('period')).toBe('jm-1405-05');
expect(next.searchParams.get('range')).toBe('month');
expect(next.searchParams.get('terminal')).toBe('T83');
expect(next.searchParams.get('status')).toBe('Verified');
});
it('rejects reporting period identifiers outside the server formats', () => {
const filters = parseDashboardFilters(
new URLSearchParams('range=day&period=javascript:alert(1)'),
terminals,
);
expect(filters.period).toBe('');
});
it('normalizes an inverted amount range', () => {
const filters = parseDashboardFilters(
new URLSearchParams('minAmount=900000&maxAmount=100000'),

View File

@@ -3,6 +3,7 @@ export type TransactionStatus = 'all' | 'Paid' | 'Verified' | 'Failed' | 'Revers
export interface DashboardFilters {
range: DashboardRange;
period: string;
terminal: string;
status: TransactionStatus;
minAmount: number | null;
@@ -12,6 +13,13 @@ export interface DashboardFilters {
const ranges = new Set<DashboardRange>(['day', 'week', 'month', 'season']);
const statuses = new Set<TransactionStatus>(['all', 'Paid', 'Verified', 'Failed', 'Reversed']);
const periodPatterns: Record<DashboardRange, RegExp> = {
day: /^d-\d{4}-\d{2}-\d{2}$/,
week: /^w-\d{4}-\d{2}-\d{2}$/,
month: /^jm-\d{4}-\d{2}$/,
season: /^ps-\d{4}-(?:01|04|07|10)$/,
};
function positiveAmount(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
@@ -23,6 +31,8 @@ export function parseDashboardFilters(
terminalIds: readonly string[] = [],
): DashboardFilters {
const rawRange = query.get('range') as DashboardRange | null;
const range = rawRange && ranges.has(rawRange) ? rawRange : 'month';
const rawPeriod = query.get('period');
const rawStatus = query.get('status') as TransactionStatus | null;
const rawTerminal = query.get('terminal');
let minAmount = positiveAmount(query.get('minAmount'));
@@ -33,7 +43,8 @@ export function parseDashboardFilters(
}
return {
range: rawRange && ranges.has(rawRange) ? rawRange : 'month',
range,
period: rawPeriod && periodPatterns[range].test(rawPeriod) ? rawPeriod : '',
terminal: rawTerminal && terminalIds.includes(rawTerminal) ? rawTerminal : 'all',
status: rawStatus && statuses.has(rawStatus) ? rawStatus : 'all',
minAmount,

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { describePercentile, formulas } from './insights';
import { describePercentile, formulas, peerProgressTone, retryOpportunity } from './insights';
describe('merchant-friendly insights', () => {
it('celebrates strong peer performance without claiming causality', () => {
@@ -19,4 +19,19 @@ describe('merchant-friendly insights', () => {
expect(formulas.paymentSuccess).toContain('پرداخت‌های موفق بانکی');
expect(formulas.paymentSuccess).toContain('همه نشست‌های پرداخت');
});
it('uses semantic progress tones as peer performance improves', () => {
expect(peerProgressTone(18)).toBe('needs-attention');
expect(peerProgressTone(42)).toBe('developing');
expect(peerProgressTone(61)).toBe('strong');
expect(peerProgressTone(88)).toBe('leading');
});
it('changes recovery tone and copy at meaningful retry thresholds', () => {
expect(retryOpportunity(72, 150).tone).toBe('excellent');
expect(retryOpportunity(48, 80).tone).toBe('positive');
expect(retryOpportunity(24, 80).tone).toBe('opportunity');
expect(retryOpportunity(72, 20).tone).toBe('opportunity');
expect(retryOpportunity(72, 150).copy).not.toBe(retryOpportunity(24, 80).copy);
});
});

View File

@@ -26,3 +26,40 @@ export function describePercentile(metricId: string, percentile: number): string
}
return `در ${name} هنوز فرصت رشد خوبی دارید؛ بررسی مسیرهای ناموفق می‌تواند قدم بعدی روشنی نشان دهد.`;
}
export type PeerProgressTone = 'needs-attention' | 'developing' | 'strong' | 'leading';
export function peerProgressTone(percentile: number): PeerProgressTone {
if (percentile >= 75) return 'leading';
if (percentile >= 50) return 'strong';
if (percentile >= 25) return 'developing';
return 'needs-attention';
}
export type RetryOpportunity = {
tone: 'excellent' | 'positive' | 'opportunity';
title: string;
copy: string;
};
export function retryOpportunity(rate: number, retriedSessions: number): RetryOpportunity {
if (rate >= 65 && retriedSessions >= 100) {
return {
tone: 'excellent',
title: 'بازگشت قدرتمند مشتریان',
copy: 'بخش بزرگی از مشتریانی که دوباره تلاش کرده‌اند به پرداخت موفق رسیده‌اند؛ این روند نشان می‌دهد مسیر بازگشت فعلی قابل اتکاست.',
};
}
if (rate >= 35 && retriedSessions >= 50) {
return {
tone: 'positive',
title: 'روند بازیابی رو به جلوست',
copy: 'بخشی از مشتریان در تلاش دوباره موفق شده‌اند. مرور مرحله‌های ناموفق می‌تواند همین مسیر مثبت را تقویت کند.',
};
}
return {
tone: 'opportunity',
title: 'فرصتی برای ساده‌تر کردن بازگشت',
copy: 'مشتریان دوباره امتحان کرده‌اند؛ کوتاه‌تر کردن مسیر خطا و روشن‌تر کردن راهنمای تلاش مجدد می‌تواند شانس موفقیت بعدی را بیشتر کند.',
};
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import type { ReportingPeriod } from '$lib/api/client';
import {
availableJalaliMonths,
buildPersianMonthGrid,
buildPersianWeekMonthGrid,
groupPeriodsByJalaliYear,
weekDaysForPeriod,
} from './reporting-period-selection';
const periods: ReportingPeriod[] = [
{
periodId: 'd-2026-08-20',
periodType: 'DAY',
startAt: '2026-08-19T20:30:00Z',
endAt: '2026-08-20T20:30:00Z',
localStartAt: '2026-08-20T00:00:00+03:30',
localEndAt: '2026-08-21T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'روز ۲۹ مرداد ۱۴۰۵',
isComplete: true,
isDefault: false,
},
{
periodId: 'w-2026-08-15',
periodType: 'WEEK',
startAt: '2026-08-14T20:30:00Z',
endAt: '2026-08-21T20:30:00Z',
localStartAt: '2026-08-15T00:00:00+03:30',
localEndAt: '2026-08-22T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'هفته ۲۴ تا ۳۰ مرداد ۱۴۰۵',
isComplete: true,
isDefault: false,
},
{
periodId: 'jm-1405-05',
periodType: 'JALALI_MONTH',
startAt: '2026-07-22T20:30:00Z',
endAt: '2026-08-22T20:30:00Z',
localStartAt: '2026-07-23T00:00:00+03:30',
localEndAt: '2026-08-23T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'مرداد ۱۴۰۵',
isComplete: true,
isDefault: true,
},
{
periodId: 'ps-1404-10',
periodType: 'PERSIAN_SEASON',
startAt: '2025-12-21T20:30:00Z',
endAt: '2026-03-20T20:30:00Z',
localStartAt: '2025-12-22T00:00:00+03:30',
localEndAt: '2026-03-21T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'زمستان ۱۴۰۴',
isComplete: true,
isDefault: false,
},
];
describe('reporting period selection', () => {
it('groups only server-offered periods by Jalali year and range', () => {
const result = groupPeriodsByJalaliYear(periods, 'month');
expect([...result.keys()]).toEqual([1405]);
expect(result.get(1405)?.map((period) => period.periodId)).toEqual(['jm-1405-05']);
});
it('builds a Saturday-first Persian month grid and enables only offered days', () => {
const grid = buildPersianMonthGrid(1405, 5, periods);
const enabled = grid.filter((day) => day.periodId);
expect(grid).toHaveLength(42);
expect(grid[0]?.weekday).toBe(0);
expect(enabled).toEqual([
expect.objectContaining({ day: 29, inCurrentMonth: true, periodId: 'd-2026-08-20' }),
]);
});
it('lists only Jalali months that contain an offered day in the chosen year', () => {
expect(availableJalaliMonths(periods, 1405)).toEqual([5]);
expect(availableJalaliMonths(periods, 1404)).toEqual([]);
});
it('returns the complete Saturday-to-Friday row for a server-offered week', () => {
const days = weekDaysForPeriod(periods[1]);
expect(days).toHaveLength(7);
expect(days[0]).toEqual(expect.objectContaining({ weekday: 0, periodId: 'w-2026-08-15' }));
expect(days[6]).toEqual(expect.objectContaining({ weekday: 6, periodId: 'w-2026-08-15' }));
});
it('maps every selectable day in a week calendar to the same offered week', () => {
const grid = buildPersianWeekMonthGrid(1405, 5, periods);
const weekCells = grid.filter((day) => day.periodId === 'w-2026-08-15');
expect(weekCells).toHaveLength(7);
expect(new Set(weekCells.map((day) => day.weekday))).toEqual(new Set([0, 1, 2, 3, 4, 5, 6]));
});
});

View File

@@ -0,0 +1,139 @@
import { CalendarDate, PersianCalendar, fromDate, toCalendar } from '@internationalized/date';
import type { ReportingPeriod } from '$lib/api/client';
import { rangePeriodTypes, type DashboardRange } from '$lib/dashboard-filters';
export interface PersianCalendarDay {
key: string;
year: number;
month: number;
day: number;
weekday: number;
inCurrentMonth: boolean;
periodId: string | null;
label: string;
}
const persianCalendar = new PersianCalendar();
const persianNumber = new Intl.NumberFormat('fa-IR', { useGrouping: false });
function toPersianDate(value: string, timezone = 'Asia/Tehran') {
return toCalendar(fromDate(new Date(value), timezone), persianCalendar);
}
export function periodJalaliDateParts(period: ReportingPeriod) {
const date = toPersianDate(period.localStartAt, period.timezone);
return { year: date.year, month: date.month, day: date.day };
}
export function periodJalaliYear(period: ReportingPeriod): number {
const encodedYear = /^(?:jm|ps)-(\d{4})-/.exec(period.periodId)?.[1];
return encodedYear ? Number(encodedYear) : toPersianDate(period.localStartAt, period.timezone).year;
}
export function groupPeriodsByJalaliYear(
periods: readonly ReportingPeriod[],
range: DashboardRange,
): Map<number, ReportingPeriod[]> {
const grouped = new Map<number, ReportingPeriod[]>();
for (const period of periods) {
if (period.periodType !== rangePeriodTypes[range] || !period.isComplete) continue;
const year = periodJalaliYear(period);
grouped.set(year, [...(grouped.get(year) ?? []), period]);
}
return new Map(
[...grouped.entries()]
.sort(([left], [right]) => right - left)
.map(([year, offered]) => [
year,
offered.sort((left, right) => left.localStartAt.localeCompare(right.localStartAt)),
]),
);
}
function dayKey(date: { year: number; month: number; day: number }) {
return `${date.year}-${date.month}-${date.day}`;
}
export function buildPersianMonthGrid(
year: number,
month: number,
periods: readonly ReportingPeriod[],
): PersianCalendarDay[] {
const offeredDays = new Map(
periods
.filter((period) => period.periodType === 'DAY' && period.isComplete)
.map((period) => [dayKey(toPersianDate(period.localStartAt, period.timezone)), period.periodId]),
);
return buildMonthGrid(year, month, offeredDays);
}
function buildMonthGrid(year: number, month: number, offeredDays: ReadonlyMap<string, string>) {
const firstDay = new CalendarDate(persianCalendar, year, month, 1);
const leadingDays = (firstDay.toDate('Asia/Tehran').getDay() + 1) % 7;
const gridStart = firstDay.subtract({ days: leadingDays });
return Array.from({ length: 42 }, (_, index) => {
const date = gridStart.add({ days: index });
return {
key: dayKey(date),
year: date.year,
month: date.month,
day: date.day,
weekday: index % 7,
inCurrentMonth: date.year === year && date.month === month,
periodId: offeredDays.get(dayKey(date)) ?? null,
label: persianNumber.format(date.day),
};
});
}
export function buildPersianWeekMonthGrid(
year: number,
month: number,
periods: readonly ReportingPeriod[],
): PersianCalendarDay[] {
const offeredDays = new Map<string, string>();
for (const period of periods) {
if (period.periodType !== 'WEEK' || !period.isComplete) continue;
for (const day of weekDaysForPeriod(period)) offeredDays.set(dayKey(day), period.periodId);
}
return buildMonthGrid(year, month, offeredDays);
}
export function availableJalaliMonths(
periods: readonly ReportingPeriod[],
year: number,
range: 'day' | 'week' = 'day',
): number[] {
const months = new Set<number>();
for (const period of periods) {
if (!period.isComplete) continue;
if (range === 'day' && period.periodType === 'DAY') {
const day = periodJalaliDateParts(period);
if (day.year === year) months.add(day.month);
}
if (range === 'week' && period.periodType === 'WEEK') {
for (const day of weekDaysForPeriod(period)) if (day.year === year) months.add(day.month);
}
}
return [...months].sort((left, right) => left - right);
}
export function weekDaysForPeriod(period: ReportingPeriod): PersianCalendarDay[] {
const firstDay = toPersianDate(period.localStartAt, period.timezone);
return Array.from({ length: 7 }, (_, index) => {
const date = firstDay.add({ days: index });
return {
key: `${period.periodId}-${index}`,
year: date.year,
month: date.month,
day: date.day,
weekday: index,
inCurrentMonth: true,
periodId: period.periodId,
label: persianNumber.format(date.day),
};
});
}