fix: refine responsive dashboard filters
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
7
src/lib/components/ui/slider/index.ts
Normal file
7
src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './slider.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
50
src/lib/components/ui/slider/slider.svelte
Normal file
50
src/lib/components/ui/slider/slider.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user