feat: redesign StaffPage with KPI bar, improved table layout, and enhanced form fields

This commit is contained in:
hamed
2026-06-14 23:36:37 +03:30
parent 5be63a23b6
commit 55f646e2d4
6 changed files with 1118 additions and 258 deletions
+242 -90
View File
@@ -4,6 +4,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon,
} from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { SmsWalletBalance, SmsWalletLog, SmsSettings } from '../types';
@@ -46,6 +49,7 @@ export default function SmsWalletPage() {
const settings = settingsData?.data;
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
const watchAmount = chargeForm.watch('amount_rials');
const chargeMutation = useMutation({
mutationFn: ({ amount_rials }: ChargeForm) =>
@@ -67,26 +71,75 @@ export default function SmsWalletPage() {
onError: (e: any) => toast.error(e.message),
});
const smsCount = balance?.estimated_sms_count ?? 0;
const isLowBalance = smsCount < 10;
return (
<>
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
{/* کارت موجودی */}
<div className="card">
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 4 }}>موجودی کیف پول</div>
{/* کارت موجودی — gradient */}
<div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
borderRadius: 'var(--r)',
padding: '24px 24px 20px',
color: '#fff',
position: 'relative',
overflow: 'hidden',
}}>
{/* دایره تزئینی */}
<div style={{
position: 'absolute', top: -30, left: -30,
width: 140, height: 140, borderRadius: '50%',
background: 'rgba(255,255,255,0.06)',
}} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 13, opacity: 0.85 }}>موجودی کیف پول</div>
<span style={{
fontSize: 11.5, fontWeight: 600, padding: '3px 10px', borderRadius: 20,
background: isLowBalance ? 'rgba(239,68,68,0.3)' : 'rgba(34,197,94,0.3)',
border: `1px solid ${isLowBalance ? 'rgba(239,68,68,0.5)' : 'rgba(34,197,94,0.5)'}`,
}}>
{isLowBalance ? 'موجودی کم' : 'فعال'}
</span>
</div>
{balanceLoading ? (
<div style={{ height: 40 }} />
<div style={{ height: 60 }} />
) : (
<>
<div style={{ fontSize: 28, fontWeight: 700, marginBottom: 4 }}>
<div style={{ fontSize: 36, fontWeight: 800, letterSpacing: '-0.5px', marginBottom: 4 }}>
{formatRial(balance?.balance_rials ?? 0)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 16 }}>
معادل {formatNumber(balance?.estimated_sms_count ?? 0)} پیامک
<div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}>
معادل {formatNumber(smsCount)} پیامک
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
</div>
<button className="btn primary sm" onClick={() => setChargeOpen(true)}>
{/* نوار پیشرفت */}
<div style={{
background: 'rgba(255,255,255,0.2)', borderRadius: 8, height: 6, marginBottom: 16, overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 8,
width: `${Math.min(100, (smsCount / 500) * 100)}%`,
background: isLowBalance ? '#ef4444' : 'rgba(255,255,255,0.85)',
transition: 'width 0.4s ease',
}} />
</div>
<button
style={{
background: '#fff', color: 'var(--primary)',
border: 'none', borderRadius: 8, padding: '8px 18px',
fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
onClick={() => setChargeOpen(true)}
>
<DevicePhoneMobileIcon style={{ width: 16 }} />
شارژ کیف پول
</button>
</>
@@ -95,99 +148,167 @@ export default function SmsWalletPage() {
{/* تنظیمات پیامک */}
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 12 }}>تنظیمات ارسال</div>
{currentSettings ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
<input
type="checkbox"
checked={currentSettings.reminder_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
/>
یادآوری قبل از نوبت
</label>
{currentSettings.reminder_enabled && (
<div className="field" style={{ marginBottom: 0 }}>
<label style={{ fontSize: 13 }}>چند ساعت قبل</label>
<div style={{
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<DevicePhoneMobileIcon style={{ width: 16, color: 'var(--primary)' }} />
تنظیمات ارسال
</div>
<div style={{ padding: '16px' }}>
{currentSettings ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* toggle یادآوری */}
<div style={{
background: currentSettings.reminder_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
border: `1px solid ${currentSettings.reminder_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`,
borderRadius: 8, padding: '10px 14px',
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
transition: 'all 0.15s',
}}
onClick={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
>
<input
type="number"
min={1}
max={72}
value={currentSettings.reminder_hours_before}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
dir="ltr"
style={{ width: 80 }}
type="checkbox"
checked={currentSettings.reminder_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
</div>
</div>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
<input
type="checkbox"
checked={currentSettings.post_visit_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
/>
پیامک بعد از ویزیت
</label>
{currentSettings.post_visit_enabled && (
<div className="field" style={{ marginBottom: 0 }}>
<label style={{ fontSize: 13 }}>متن پیامک</label>
<textarea
value={currentSettings.post_visit_text ?? ''}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
rows={3}
placeholder="ممنون از مراجعه شما..."
{currentSettings.reminder_enabled && (
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
<label style={{ fontSize: 13 }}>چند ساعت قبل از نوبت</label>
<input
type="number" min={1} max={72}
value={currentSettings.reminder_hours_before}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
dir="ltr"
style={{ width: 90 }}
/>
</div>
)}
{/* toggle بعد از ویزیت */}
<div style={{
background: currentSettings.post_visit_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
border: `1px solid ${currentSettings.post_visit_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`,
borderRadius: 8, padding: '10px 14px',
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
transition: 'all 0.15s',
}}
onClick={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
>
<input
type="checkbox"
checked={currentSettings.post_visit_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>پیامک بعد از ویزیت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال متن تشکر پس از پایان مراجعه</div>
</div>
</div>
)}
<button
className="btn primary sm"
style={{ alignSelf: 'flex-start' }}
disabled={saveMutation.isPending}
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</button>
</div>
) : (
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
)}
{currentSettings.post_visit_enabled && (
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
<label style={{ fontSize: 13 }}>متن پیامک</label>
<textarea
value={currentSettings.post_visit_text ?? ''}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
rows={3}
placeholder="ممنون از مراجعه شما..."
/>
</div>
)}
<button
className="btn primary sm"
style={{ alignSelf: 'flex-start', marginTop: 4 }}
disabled={saveMutation.isPending}
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</button>
</div>
) : (
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
)}
</div>
</div>
</div>
{/* تاریخچه تراکنش‌ها */}
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 12 }}>تاریخچه تراکنشها</div>
<div style={{
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8,
}}>
تاریخچه تراکنشها
{!logsLoading && total > 0 && (
<span className="badge gray" style={{ fontSize: 11 }}>{total} تراکنش</span>
)}
</div>
{logsLoading ? (
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '16px' }}>در حال بارگذاری...</div>
) : logs.length === 0 ? (
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>تراکنشی ثبت نشده است</div>
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '32px 16px', textAlign: 'center' }}>
تراکنشی ثبت نشده است
</div>
) : (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '8px 4px' }}>نوع</th>
<th style={{ textAlign: 'right', padding: '8px 4px' }}>مبلغ</th>
<th style={{ textAlign: 'right', padding: '8px 4px' }}>توضیحات</th>
<th style={{ textAlign: 'right', padding: '8px 4px' }}>تاریخ</th>
<tr style={{ background: 'oklch(0.97 0.01 256)', borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>نوع</th>
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>مبلغ</th>
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>توضیحات</th>
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>تاریخ</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 4px' }}>
<span className={`badge ${log.type === 'credit' ? 'green' : 'red'}`}>
{log.type === 'credit' ? 'شارژ' : 'کسر'}
</span>
{logs.map((log, idx) => (
<tr
key={log.uuid}
style={{
borderBottom: '1px solid var(--border)',
background: idx % 2 === 1 ? 'oklch(0.985 0.005 256)' : 'transparent',
}}
>
<td style={{ padding: '11px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{log.type === 'credit' ? (
<ArrowUpCircleIcon style={{ width: 18, color: 'oklch(0.52 0.17 162)', flexShrink: 0 }} />
) : (
<ArrowDownCircleIcon style={{ width: 18, color: 'oklch(0.52 0.2 25)', flexShrink: 0 }} />
)}
<span className={`badge ${log.type === 'credit' ? 'green' : 'red'}`}>
{log.type === 'credit' ? 'شارژ' : 'کسر'}
</span>
</div>
</td>
<td style={{ padding: '8px 4px' }}>{formatRial(log.amount_rials)}</td>
<td style={{ padding: '8px 4px', color: 'var(--text-2)' }}>{log.description}</td>
<td style={{ padding: '8px 4px', color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</td>
<td style={{
padding: '11px 16px',
fontWeight: 600,
color: log.type === 'credit' ? 'oklch(0.45 0.17 162)' : 'oklch(0.45 0.2 25)',
}}>
{formatRial(log.amount_rials)}
</td>
<td style={{ padding: '11px 16px', color: 'var(--text-2)' }}>{log.description}</td>
<td style={{ padding: '11px 16px', color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ marginTop: 12 }}>
<div style={{ padding: '12px 16px' }}>
<Pagination page={logPage} total={total} limit={20} onPageChange={setLogPage} />
</div>
</>
@@ -197,26 +318,57 @@ export default function SmsWalletPage() {
{/* Modal شارژ */}
<Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک">
<form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
{(['mellat', 'sep'] as const).map((gw) => (
<button
key={gw}
type="button"
className={`btn ${gateway === gw ? 'primary' : ''}`}
onClick={() => setGateway(gw)}
>
{gw === 'mellat' ? 'ملت' : 'سپ'}
</button>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* انتخاب درگاه — radio cards */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>انتخاب درگاه پرداخت</div>
<div style={{ display: 'flex', gap: 10 }}>
{(['mellat', 'sep'] as const).map((gw) => (
<div
key={gw}
onClick={() => setGateway(gw)}
style={{
flex: 1, textAlign: 'center', padding: '12px',
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
borderRadius: 10, cursor: 'pointer',
background: gateway === gw ? 'var(--primary-subtle)' : 'transparent',
transition: 'all 0.15s',
}}
>
<div style={{ fontWeight: 700, fontSize: 15, color: gateway === gw ? 'var(--primary)' : 'var(--text-1)' }}>
{gw === 'mellat' ? 'ملت' : 'سپ'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 3 }}>
{gw === 'mellat' ? 'بانک ملت' : 'بانک صادرات'}
</div>
</div>
))}
</div>
</div>
<div className="field">
<label>مبلغ (ریال)</label>
<input {...chargeForm.register('amount_rials')} type="number" min={10000} placeholder="500000" dir="ltr" />
<input
{...chargeForm.register('amount_rials')}
type="number" min={10000}
placeholder="500000" dir="ltr"
/>
{chargeForm.formState.errors.amount_rials && (
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
)}
</div>
{watchAmount && Number(watchAmount) >= 10000 && (
<div style={{
background: 'var(--primary-subtle)',
border: '1px solid oklch(0.85 0.06 256)',
borderRadius: 8, padding: '10px 14px',
fontSize: 13, color: 'var(--primary)', fontWeight: 500,
}}>
پرداخت {formatRial(Number(watchAmount))} از طریق {gateway === 'mellat' ? 'بانک ملت' : 'سپ'}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn primary" disabled={chargeMutation.isPending}>
{chargeMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}