feat: enrich dashboard filters and feedback
This commit is contained in:
173
src/lib/components/AmountRangeFilter.svelte
Normal file
173
src/lib/components/AmountRangeFilter.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
src/lib/components/FeeShareDonut.svelte
Normal file
55
src/lib/components/FeeShareDonut.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
245
src/lib/components/PeriodPicker.svelte
Normal file
245
src/lib/components/PeriodPicker.svelte
Normal 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>
|
||||
59
src/lib/components/TerminalSelect.svelte
Normal file
59
src/lib/components/TerminalSelect.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: 'مشتریان دوباره امتحان کردهاند؛ کوتاهتر کردن مسیر خطا و روشنتر کردن راهنمای تلاش مجدد میتواند شانس موفقیت بعدی را بیشتر کند.',
|
||||
};
|
||||
}
|
||||
|
||||
102
src/lib/reporting-period-selection.test.ts
Normal file
102
src/lib/reporting-period-selection.test.ts
Normal 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]));
|
||||
});
|
||||
});
|
||||
139
src/lib/reporting-period-selection.ts
Normal file
139
src/lib/reporting-period-selection.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user