fix: refine responsive dashboard filters

This commit is contained in:
2026-08-20 22:54:20 +03:30
parent 458ab69a3c
commit b599cdfba4
12 changed files with 299 additions and 284 deletions

View File

@@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti
| Version | Date | Decision |
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.7.0 | 2026-08-20 | Made period changes confirm-on-selection, moved narrow-screen filters into a bottom sheet, enlarged collapsed navigation targets, simplified focus rings, and promoted the fee-share chart into a dedicated visual column. |
| 0.6.0 | 2026-08-20 | Added full-span Jalali period selection, unified transaction status/amount controls, persistent skeleton states, custom RTL dropdowns, semantic peer/retry tones, and fee-share visualization; tightened the narrow navbar and 600px KPI breakpoint. |
| 0.5.0 | 2026-08-20 | Reconciled the approved baseline with the shipped routed frontend and desktop, mobile, and 639px review captures; converted the file to canonical DESIGN.md format and added the design sidecar. |
| 0.4.0 | 2026-08-20 | Adopted the current ZarinPal panel as the closer interaction reference: top wordmark bar, 256/72px collapsible right sidebar, 8px data surfaces, routed sections, URL-backed period and terminal filters, payment-history chart, transaction table, attempt history, formula help, and supportive insights. |
@@ -197,7 +198,7 @@ The palette is a neutral financial canvas with one decisive blue voice, a very s
### Hierarchy
- **Page Heading** (800, 20px; 24px from 640px, 1.4): Routed page identity at the top of the workspace.
- **Section Heading** (700, 20px; 24px from 640px, 1.5): Major analytics modules and methodology groups.
- **Section Heading** (700, 20px; 24px from 640px, 1.5): Major analytics modules and explanatory groups.
- **Metric** (800, 24px, 1.25): KPI values, amounts, rates, and transaction totals; use tabular numerals.
- **Body** (400, 15px, 1.8): Default interface copy and longer Persian explanations.
- **Supporting** (400, 14px, usually 1.75): Descriptions, filters, table content, and insight summaries.
@@ -245,7 +246,7 @@ The form language is low-radius and precise. Data cards, filter bars, inputs, se
- **Shape:** Compact, bordered or filled, with 6–8px corners and 32–36px desktop height; touch layouts raise interactive targets to at least 44px.
- **Primary:** Action blue with white content in light mode and dark ink on the brighter night action blue.
- **Hover / Focus:** Hover changes tone; active state moves down by 1px where the action is press-like. Keyboard focus uses a visible 3px semantic blue ring. State transitions stay around 160ms.
- **Hover / Focus:** Hover changes tone; active state moves down by 1px where the action is press-like. Keyboard focus uses a restrained 1px semantic blue ring plus the border shift. State transitions stay around 160ms.
- **Ghost:** Transparent at rest, muted-neutral on hover, used for inactive range choices and utility icons.
### Chips
@@ -264,7 +265,7 @@ The form language is low-radius and precise. Data cards, filter bars, inputs, se
### Inputs / Fields
- **Style:** Transparent or workspace-toned field, 1px input border, 6–8px radius, 32–36px desktop height, 44px touch height, and compact 12–14px text. Terminal and status selects use the same RTL popover primitive rather than the browser-native menu.
- **Focus:** Border shifts to the ring color and gains a visible 3px half-opacity blue ring.
- **Focus:** Border shifts to the ring color and gains a restrained 1px low-opacity blue ring.
- **Error / Disabled:** Error uses danger border plus ring; disabled fields reduce opacity and preserve legible text.
### Navigation
@@ -279,7 +280,7 @@ The signature KPI card pairs a muted label and optional formula help with a sema
### Reporting Scope Filter
Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Exact period selection opens a compact RTL popover: a four-digit Jalali year input, month navigation plus calendar grid for day/week, whole-week hover and selection, and named month/season choices. Transaction status and a two-thumb RTL amount range join the same bar after a divider. Backend-offered complete periods are the selectable authority.
Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Clicking a period type first opens the exact-period picker; the URL and backend request change only after a concrete period is chosen. The compact RTL popover provides a four-digit Jalali year input, month navigation plus calendar grid for day/week, whole-week hover and selection, and named month/season choices. Transaction status and a two-thumb RTL amount range sit to the physical right of the calendar control after a divider. Below 768px, the full set moves into a bottom sheet. Backend-offered complete periods are the selectable authority.
### Loading State
@@ -295,7 +296,7 @@ The table stays inside a bordered 8px surface, receives its compact filters from
### Fee Share and Comparative Progress
The fee page pairs numeric KPIs with a compact circular share chart. Peer progress bars move from rose through amber and sky to emerald as the direction-adjusted percentile improves. Retry guidance uses amber, sky, or emerald surfaces and changes its supportive sentence at tested recovery/sample thresholds.
The fee page gives the circular share chart a large physical-right column while three numeric KPI cards stack in the adjacent column. Peer progress bars move from rose through amber and sky to emerald as the direction-adjusted percentile improves. Retry guidance uses amber, sky, or emerald surfaces and changes its supportive sentence at tested recovery/sample thresholds.
## Do's and Don'ts

View File

@@ -2,6 +2,7 @@
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import * as Popover from '$lib/components/ui/popover';
import { Slider } from '$lib/components/ui/slider';
import { formatValue } from '$lib/format';
const LIMIT = 10_000_000_000;
@@ -20,21 +21,20 @@
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);
let sliderValue = $state([0, LIMIT]);
$effect(() => {
localMin = Math.max(0, Math.min(minAmount ?? 0, LIMIT));
localMax = Math.max(localMin, Math.min(maxAmount ?? LIMIT, LIMIT));
sliderValue = [localMin, localMax];
});
function updateMin(value: number) {
localMin = Math.min(value, localMax - STEP);
}
function updateMax(value: number) {
localMax = Math.max(value, localMin + STEP);
function updateRange(value: number[]) {
const [nextMin = 0, nextMax = LIMIT] = [...value].sort((left, right) => left - right);
localMin = nextMin;
localMax = nextMax;
sliderValue = [nextMin, nextMax];
}
function apply() {
@@ -45,7 +45,7 @@
<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"
class="border-input bg-background hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/30 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-1 focus-visible:outline-none sm:min-h-9"
aria-label="فیلتر بازه مبلغ"
aria-pressed={hasFilter}
>
@@ -64,29 +64,16 @@
<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>
<Slider
value={sliderValue}
onValueChange={updateRange}
min={0}
max={LIMIT}
step={STEP}
dir="rtl"
class="my-2 h-8"
aria-label="بازه مبلغ تراکنش"
/>
<div class="flex gap-2">
<button
@@ -107,67 +94,3 @@
</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

@@ -257,28 +257,30 @@
</Card>
</div>
{:else if kind === 'fees' && fees.state === 'READY'}
<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)}
note="مبنای محاسبه سهم کارمزد"
icon={BanknoteIcon}
tone="good"
/>
<KpiCard
label="نرخ مؤثر کارمزد"
value={formatPercent(fees.data.paidFeeRate.value)}
note="سهم کارمزد از مبلغ پرداخت‌های موفق"
icon={GaugeIcon}
formula={formulas.feeRate}
/>
<div class="grid gap-4 lg:grid-cols-[1.1fr_1fr]" dir="rtl">
<FeeShareDonut value={fees.data.paidFeeRate.value} class="min-h-[28rem]" />
<div class="grid gap-4">
<KpiCard
label="کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
note="مجموع کارمزد ثبت‌شده برای پرداخت‌های این بازه"
icon={HandCoinsIcon}
/>
<KpiCard
label="مبلغ پرداخت موفق"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
note="مبنای محاسبه سهم کارمزد"
icon={BanknoteIcon}
tone="good"
/>
<KpiCard
label="نرخ مؤثر کارمزد"
value={formatPercent(fees.data.paidFeeRate.value)}
note="سهم کارمزد از مبلغ پرداخت‌های موفق"
icon={GaugeIcon}
formula={formulas.feeRate}
/>
</div>
</div>
<Card class="mt-4 rounded-lg shadow-xs"
><CardContent class="flex items-start gap-4 p-6"

View File

@@ -3,7 +3,6 @@
import { page } from '$app/state';
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 HandCoinsIcon from '@lucide/svelte/icons/hand-coins';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MenuIcon from '@lucide/svelte/icons/menu';
@@ -71,8 +70,10 @@
<Sidebar.Provider class="dashboard-shell" style="--sidebar-width: 16rem; --sidebar-width-icon: 4.5rem;">
<Sidebar.Root side="right" collapsible="icon" class="top-14 h-[calc(100svh-3.5rem)] border-l">
<Sidebar.Header class="border-sidebar-border gap-2 border-b p-2.5">
<div class="terminal-card">
<WalletCardsIcon class="size-5 shrink-0" />
<div
class="terminal-card group-data-[collapsible=icon]:size-11 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0"
>
<WalletCardsIcon class="size-5 shrink-0 group-data-[collapsible=icon]:size-[22px]" />
<div class="min-w-0 group-data-[collapsible=icon]:hidden">
<strong class="block truncate text-sm">{dashboard.session?.merchant.displayName}</strong>
<span class="block truncate text-[11px] text-white/75">حساب تحلیل پذیرنده</span>
@@ -99,7 +100,7 @@
<Sidebar.MenuButton
isActive={isActive(item.href)}
tooltipContent={item.label}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px] group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-[21px]!"
>
{#snippet child({ props })}
<a
@@ -117,36 +118,15 @@
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
<Sidebar.Separator class="my-2" />
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={isActive('/methodology')}
tooltipContent="روش محاسبه"
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
{#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>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-sidebar-border border-t p-2">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent="خروج" class="h-11 px-3" onclick={logout}>
<Sidebar.MenuButton
tooltipContent="خروج"
class="h-11 px-3 group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-5!"
onclick={logout}
>
<LogOutIcon class="size-[18px]" />
<span>خروج از حساب</span>
</Sidebar.MenuButton>

View File

@@ -3,9 +3,11 @@
import { page } from '$app/state';
import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import * as Sheet from '$lib/components/ui/sheet';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import {
parseDashboardFilters,
@@ -20,102 +22,185 @@
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
const isTransactionsPage = $derived(page.url.pathname === '/transactions');
const currentPeriod = $derived(dashboard.periodFor(filters.range, filters.period));
let pickerRange = $state<DashboardRange>('month');
let desktopPickerOpen = $state(false);
let mobilePickerOpen = $state(false);
let mobileFiltersOpen = $state(false);
$effect(() => {
if (!desktopPickerOpen && !mobilePickerOpen) pickerRange = filters.range;
});
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 });
function openPeriodPicker(range: DashboardRange, mobile: boolean) {
pickerRange = range;
if (mobile) mobilePickerOpen = true;
else desktopPickerOpen = true;
}
function applyPeriod(period: string) {
update({ range: pickerRange, period });
desktopPickerOpen = false;
mobilePickerOpen = false;
mobileFiltersOpen = false;
}
</script>
<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="min-h-11 rounded-md px-2.5 text-xs sm:min-h-9"
aria-pressed={filters.range === range}
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"
{#snippet periodControls(mobile: boolean)}
<div class={mobile ? 'grid gap-4' : 'flex min-w-0 items-center gap-3'}>
<div
class={mobile ? 'grid grid-cols-2 gap-2' : 'flex max-w-full items-center gap-1 overflow-x-auto'}
aria-label="بازه گزارش"
>
{#if !mobile}<CalendarRangeIcon class="text-muted-foreground mx-1 hidden size-4 lg:block" />{/if}
{#each Object.entries(rangeLabels) as [range, label]}
<Button
variant={(desktopPickerOpen || mobilePickerOpen) && pickerRange === range
? 'default'
: filters.range === range
? 'secondary'
: 'ghost'}
size="sm"
class={mobile ? 'min-h-11 rounded-md px-3 text-xs' : 'min-h-9 rounded-md px-2.5 text-xs'}
aria-pressed={filters.range === range}
onclick={() => openPeriodPicker(range as DashboardRange, mobile)}>{label}</Button
>
<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">
{/each}
</div>
{#if mobile}
<PeriodPicker
periods={dashboard.periods}
range={pickerRange}
periodId={pickerRange === filters.range ? filters.period : ''}
bind:open={mobilePickerOpen}
onselect={applyPeriod}
/>
{:else}
<PeriodPicker
periods={dashboard.periods}
range={pickerRange}
periodId={pickerRange === filters.range ? filters.period : ''}
bind:open={desktopPickerOpen}
onselect={applyPeriod}
/>
{/if}
</div>
{/snippet}
{#snippet transactionFilters()}
<div class="flex flex-wrap items-center gap-2">
<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 md: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.Group>
<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>
</Select.Group>
</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),
})}
/>
<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 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 md:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{/snippet}
<div class="bg-card hidden min-h-14 items-center justify-end gap-3 rounded-lg border p-2.5 shadow-xs md:flex">
{#if isTransactionsPage}
{@render transactionFilters()}
<span class="border-border h-8 border-s" aria-hidden="true"></span>
{/if}
{@render periodControls(false)}
</div>
<div class="bg-card flex items-center justify-between gap-3 rounded-lg border p-2.5 shadow-xs md:hidden">
<div class="min-w-0">
<span class="text-muted-foreground block text-[11px]">بازه گزارش</span>
<strong class="block truncate text-xs">{currentPeriod?.jalaliLabelFa ?? 'انتخاب بازه'}</strong>
</div>
<Sheet.Root bind:open={mobileFiltersOpen}>
<Sheet.Trigger
class="border-input bg-background hover:bg-muted inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-semibold"
>
<SlidersHorizontalIcon class="size-4" />
فیلترها
</Sheet.Trigger>
<Sheet.Content
side="bottom"
dir="rtl"
class="max-h-[88svh] overflow-y-auto rounded-t-xl px-4 pt-5 pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<Sheet.Header class="text-right">
<Sheet.Title>فیلتر گزارش</Sheet.Title>
<Sheet.Description>بازه دقیق و فیلترهای این صفحه را انتخاب کنید.</Sheet.Description>
</Sheet.Header>
{#if isTransactionsPage}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">فیلتر تراکنش‌ها</h2>
{@render transactionFilters()}
</section>
<div class="border-border border-t"></div>
{/if}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">بازه زمانی</h2>
{@render periodControls(true)}
</section>
</Sheet.Content>
</Sheet.Root>
</div>

View File

@@ -4,15 +4,16 @@
import { Card, CardContent } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { formatPercent } from '$lib/format';
import { cn } from '$lib/utils';
let { value }: { value: string | null } = $props();
let { value, class: className }: { value: string | null; class?: string } = $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">
<Card class={cn('rounded-lg shadow-xs', className)}>
<CardContent class="flex h-full flex-col items-center justify-center gap-5 p-6 text-center sm:p-8">
<div
class="relative grid size-24 shrink-0 place-items-center"
class="relative grid size-40 shrink-0 place-items-center sm:size-48"
role="img"
aria-label={`سهم کارمزد ${formatPercent(value)}`}
>
@@ -31,10 +32,10 @@
class="transition-[stroke-dasharray] duration-700 motion-reduce:transition-none"
/>
</svg>
<strong class="absolute text-sm tabular-nums">{formatPercent(value)}</strong>
<strong class="absolute text-xl font-extrabold tabular-nums">{formatPercent(value)}</strong>
</div>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<div class="max-w-sm min-w-0">
<div class="flex items-center justify-center gap-1.5">
<h2 class="text-sm font-semibold">سهم کارمزد از پرداخت موفق</h2>
<Tooltip.Root>
<Tooltip.Trigger
@@ -47,7 +48,7 @@
>
</Tooltip.Root>
</div>
<p class="text-muted-foreground mt-2 text-xs leading-6">
<p class="text-muted-foreground mt-2 text-sm leading-7">
بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.
</p>
</div>

View File

@@ -33,15 +33,16 @@
periods,
range,
periodId,
open = $bindable(false),
onselect,
}: {
periods: readonly ReportingPeriod[];
range: DashboardRange;
periodId: string;
open?: boolean;
onselect: (periodId: string) => void;
} = $props();
let open = $state(false);
let selectedYear = $state(0);
let yearInput = $state('');
let calendarMonth = $state(1);
@@ -114,7 +115,7 @@
<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"
class="border-input bg-background hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/30 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-1 focus-visible:outline-none sm:min-h-9"
aria-label="انتخاب تاریخ دقیق گزارش"
>
<CalendarDaysIcon class="text-primary size-4 shrink-0" />
@@ -132,7 +133,7 @@
سال گزارش
<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"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/30 min-h-11 rounded-md border px-3 text-sm outline-none focus-visible:ring-1 sm:min-h-9"
type="text"
inputmode="numeric"
maxlength="4"
@@ -148,7 +149,7 @@
ماه
<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"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/30 min-h-11 cursor-pointer rounded-md border px-3 text-sm outline-none focus-visible:ring-1 sm:min-h-9"
value={calendarMonth}
onchange={(event) => (calendarMonth = Number(event.currentTarget.value))}
>
@@ -226,7 +227,7 @@
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"
class="hover:bg-primary/10 focus-visible:ring-ring/40 aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-1 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed"
onmouseenter={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}

View File

@@ -25,7 +25,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm',
className,
)}
type="file"
@@ -38,7 +38,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm',
className,
)}
{type}

View File

@@ -19,7 +19,7 @@
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}

View File

@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col',
className,
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'bg-muted bg-muted relative grow overflow-hidden rounded-full data-horizontal:h-1 data-horizontal:w-full data-horizontal:w-full data-vertical:h-full data-vertical:h-full data-vertical:w-1',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
class="border-background bg-primary ring-ring/30 relative block size-4 shrink-0 cursor-grab rounded-full border-2 shadow-sm transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-1 focus-visible:ring-1 focus-visible:outline-hidden active:cursor-grabbing active:ring-1 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View File

@@ -1,35 +0,0 @@
<script lang="ts">
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import { Card, CardContent } from '$lib/components/ui/card';
import { formulas } from '$lib/insights';
import PageHeader from '$lib/components/PageHeader.svelte';
</script>
<svelte:head><title>روش محاسبه | تحلیل زرین‌پال</title></svelte:head>
<PageHeader
title="روش محاسبه"
description="تعریف شفاف شاخص‌هایی که در گزارش‌ها می‌بینید"
icon={BookOpenIcon}
controls={false}
/>
<div class="grid gap-4 md:grid-cols-2">
{#each [['نرخ موفقیت پرداخت', formulas.paymentSuccess, 'موفقیت بانکی معیار است و تأیید پذیرنده جداگانه گزارش می‌شود.'], ['تکمیل تأیید', formulas.verification, 'نشان می‌دهد چه سهمی از پرداخت‌های موفق به تأیید نهایی رسیده‌اند.'], ['بازیابی تلاش دوباره', formulas.retryRecovery, 'فقط نشست‌هایی در مخرج هستند که بیش از یک تلاش داشته‌اند.'], ['نرخ مؤثر کارمزد', formulas.feeRate, 'مبلغ واقعی کارمزد ثبت‌شده در داده مبنای محاسبه است.']] as item}
<Card class="rounded-lg shadow-xs"
><CardContent class="p-6"
><h2 class="font-bold">{item[0]}</h2>
<code class="bg-muted mt-4 block rounded-md p-3 text-sm leading-7">{item[1]}</code>
<p class="text-muted-foreground mt-3 text-sm leading-7">{item[2]}</p></CardContent
></Card
>
{/each}
</div>
<Card class="mt-4 rounded-lg border-emerald-200/60 bg-emerald-50/40 shadow-xs dark:bg-emerald-950/10"
><CardContent class="flex items-start gap-3 p-5"
><ShieldCheckIcon class="mt-1 size-5 shrink-0 text-emerald-600" />
<p class="text-sm leading-7">
مقایسه هم‌گروه فقط زمانی نمایش داده می‌شود که تعداد کسب‌وکارها و نشست‌ها به حد امن برسد. هیچ عددی از یک
پذیرنده دیگر به‌صورت جداگانه قابل مشاهده نیست.
</p></CardContent
></Card
>