feat: redesign StaffPage with KPI bar, improved table layout, and enhanced form fields
This commit is contained in:
@@ -0,0 +1,368 @@
|
|||||||
|
# بازطراحی UI/UX پنج صفحه کلینیکپرو
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پروژه ClinicPro یک پنل مدیریت کلینیک است با React 19 + TypeScript و یک سیستم CSS دیزاین اختصاصی. SubscriptionPage اخیراً بازطراحی شده و الگویی ایجاد کرده که بقیه صفحات باید با آن همراستا شوند. ۵ صفحه هنوز از ساختار قدیمی استفاده میکنند: جدولهای ساده، کارتهای بدون سلسلهمراتب بصری، و فرمهای بدون هویت. هدف این است که هر صفحه را به یک تجربه منسجم، مدرن و RTL-first تبدیل کنیم.
|
||||||
|
|
||||||
|
## سیستم CSS موجود
|
||||||
|
|
||||||
|
متغیرها و کلاسهای قابل استفاده (در `assets/admin/styles.css`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* متغیرها */
|
||||||
|
--primary: oklch(0.55 0.2 256)
|
||||||
|
--primary-subtle: oklch(0.97 0.02 256)
|
||||||
|
--surface: #fff
|
||||||
|
--border: oklch(0.9 0 0)
|
||||||
|
--text-1 / --text-2 / --text-3
|
||||||
|
--r (border-radius base)
|
||||||
|
--gap / --card-pad
|
||||||
|
|
||||||
|
/* کلاسهای کاربردی */
|
||||||
|
.card /* white surface با shadow و border-radius */
|
||||||
|
.card-pad /* padding داخل card */
|
||||||
|
.stat /* عدد آماری بزرگ + label */
|
||||||
|
.stat-grid /* grid چند ستونه برای statها */
|
||||||
|
.badge green/red/yellow/blue/gray /* برچسب وضعیت */
|
||||||
|
.btn primary/sm/ghost /* دکمه */
|
||||||
|
.field /* label + input wrapper */
|
||||||
|
.seg /* segment/tab selector */
|
||||||
|
.bar /* نوار پیشرفت */
|
||||||
|
.grid-2 /* دوستونه responsive */
|
||||||
|
.avatar sm /* آواتار دایرهای */
|
||||||
|
```
|
||||||
|
|
||||||
|
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
|
||||||
|
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
|
||||||
|
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix |
|
||||||
|
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویسهای کلینیک — دو پنل: بخشها + آیتمها |
|
||||||
|
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
|
||||||
|
| `assets/admin/styles.css` | سیستم CSS کامل |
|
||||||
|
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحیشده با gradient + progress bar |
|
||||||
|
|
||||||
|
## وضعیت فعلی هر صفحه
|
||||||
|
|
||||||
|
### ۱. MyPatientsPage
|
||||||
|
|
||||||
|
دو حالت: حالت لیست و حالت پرونده بیمار.
|
||||||
|
|
||||||
|
**حالت لیست:** یک `.card` با `<DataTable>` ساده + search input بدون styling در header.
|
||||||
|
|
||||||
|
**حالت پرونده بیمار:** `PageHeader` با نام + برگشت. یک `.card` با `sessions.map(SessionRow)`. Modal ثبت مراجعه با سهستونه برای قیمت/بیمه/روش پرداخت.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// لیست ساده فعلی
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ padding: '12px 16px 0', display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1 }}>
|
||||||
|
<MagnifyingGlassIcon ... />
|
||||||
|
<input ... placeholder="جستجو بر اساس نام یا تلفن..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={recordColumns} data={records} loading={isLoading} ... />
|
||||||
|
<Pagination ... />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**ستونهای لیست بیماران:** آواتار + نام + تلفن، تاریخ ثبت، دکمه «پرونده».
|
||||||
|
**SessionRow:** هنوز خوانده نشده — احتمالاً نمایش تجمیعی اطلاعات مراجعه.
|
||||||
|
|
||||||
|
### ۲. StaffPage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// فعلی — جدول + دو modal
|
||||||
|
<PageHeader title="مدیریت پرسنل" ... action={<button>افزودن پرسنل</button>} />
|
||||||
|
<div className="card">
|
||||||
|
<DataTable columns={columns} data={staff} loading={isLoading} />
|
||||||
|
</div>
|
||||||
|
// Modal ایجاد و ویرایش با StaffFormFields (فیلدهای: full_name, phone, job_title, address, national_code)
|
||||||
|
```
|
||||||
|
|
||||||
|
ستونها: نام، سمت، تلفن، کد ملی، وضعیت (ActiveBadge)، تاریخ ثبت، عملیات.
|
||||||
|
هیچ KPI یا آمار مقدماتی ندارد.
|
||||||
|
|
||||||
|
### ۳. MySecretariesPage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// جدول دستی (نه DataTable)
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نام</th>
|
||||||
|
...
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{secretaries.map((s, i) => (
|
||||||
|
<tr key={s.uuid} style={{ borderBottom: ... }}>
|
||||||
|
<td style={{ padding: '10px 16px' }}><b>{s.user_name}</b></td>
|
||||||
|
...
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — دادهمحور و قابل استفاده اما بصری ضعیف.
|
||||||
|
|
||||||
|
حالت Empty state: فقط یک div با متن `هنوز منشیای ثبت نشده است`.
|
||||||
|
|
||||||
|
### ۴. ClinicServicesPage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// دو پنل side-by-side
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
{/* پنل بخشها */}
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', ... marginBottom: 12 }}>
|
||||||
|
<b style={{ fontSize: 14 }}>بخشها</b>
|
||||||
|
<button className="btn primary sm"><PlusIcon /></button>
|
||||||
|
</div>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<div style={{ padding: '8px 10px', borderRadius: 6, cursor: 'pointer', background: selected ? 'var(--primary-subtle)' : ... }}>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پنل آیتمها */}
|
||||||
|
<div className="card">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
...
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
مشکل: بخشها فقط text + دو آیکون هستند. active/inactive badge ندارند. جدول آیتمها خیلی ساده است.
|
||||||
|
|
||||||
|
### ۵. SmsWalletPage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// دو کارت بالا
|
||||||
|
<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>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700 }}>{formatRial(balance?.balance_rials ?? 0)}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>معادل {formatNumber(...)} پیامک</div>
|
||||||
|
<button className="btn primary sm">شارژ کیف پول</button>
|
||||||
|
</div>
|
||||||
|
{/* تنظیمات */}
|
||||||
|
<div className="card">...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* جدول تراکنشها */}
|
||||||
|
<div className="card">
|
||||||
|
<table style={{ ... fontSize: 13 }}>...</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
کارت موجودی: عدد بزرگ اما بدون visual hierarchy. نوار پیشرفت و badge «موجودی» ندارد.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. بازطراحی MyPatientsPage
|
||||||
|
|
||||||
|
**هدف:** لیست با identity واضح + پرونده بیمار با مینیداشبورد.
|
||||||
|
|
||||||
|
**الف — حالت لیست بیماران:**
|
||||||
|
- Header اختصاصی: `<PageHeader>` با description «مراجعهکنندگان ثبتشده شما»
|
||||||
|
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
|
||||||
|
- `DataTable` را با ردیفهای بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
|
||||||
|
- دکمه «پرونده» به آیکون `FolderOpenIcon` + متن «مشاهده پرونده» تبدیل شود (btn primary sm)
|
||||||
|
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
|
||||||
|
|
||||||
|
**ب — حالت پرونده بیمار (selectedRecord):**
|
||||||
|
- PageHeader با نام بیمار + شماره + تاریخ ثبت
|
||||||
|
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
|
||||||
|
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
|
||||||
|
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
|
||||||
|
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
|
||||||
|
- footer: لیست خدمات (services) اگر وجود داشت
|
||||||
|
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
|
||||||
|
|
||||||
|
**ج — Modal ثبت مراجعه:**
|
||||||
|
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سهستونه گذاشتن خوب است — این کافیست
|
||||||
|
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
|
||||||
|
- لیست خدمات انتخابشده را با badge یا chip نشان بده (نه فقط متن)
|
||||||
|
|
||||||
|
**نکته:** SessionRow را کامل بخوان قبل از پیادهسازی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۲. بازطراحی StaffPage
|
||||||
|
|
||||||
|
**هدف:** صفحهای با KPI bar + جدول بهتر + فرم واضحتر.
|
||||||
|
|
||||||
|
**الف — KPI bar:**
|
||||||
|
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
|
||||||
|
- داده از همان `staff` query محاسبه شود (نه API جدید):
|
||||||
|
```ts
|
||||||
|
const total = staff.length;
|
||||||
|
const active = staff.filter((s) => s.active).length;
|
||||||
|
const inactive = total - active;
|
||||||
|
```
|
||||||
|
|
||||||
|
**ب — جدول بهتر:**
|
||||||
|
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
|
||||||
|
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
|
||||||
|
- ردیفهای hover state با background تغییر کند
|
||||||
|
|
||||||
|
**ج — فرم StaffFormFields:**
|
||||||
|
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
|
||||||
|
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر
|
||||||
|
- فیلد `address` تنها بماند (textarea یا input تمام عرض)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۳. بازطراحی MySecretariesPage
|
||||||
|
|
||||||
|
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصریتر + Empty State جذاب.
|
||||||
|
|
||||||
|
**الف — جدول منشیان:**
|
||||||
|
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
|
||||||
|
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
|
||||||
|
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
|
||||||
|
- ستون «وضعیت»: `<ActiveBadge>`
|
||||||
|
- ستون «تاریخ»: `formatDate()`
|
||||||
|
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسیها + غیرفعالسازی)
|
||||||
|
|
||||||
|
**ب — Empty State:**
|
||||||
|
- وقتی `secretaries.length === 0`:
|
||||||
|
```tsx
|
||||||
|
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}>
|
||||||
|
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} />
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشیای اضافه نشده</div>
|
||||||
|
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
||||||
|
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
||||||
|
</div>
|
||||||
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**ج — PermissionsMatrix:**
|
||||||
|
- ردیفهای جدول با رنگ متناوب (striped) یا hover state
|
||||||
|
- checkboxها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
|
||||||
|
- header ستونها را bold و با background کمرنگ کن
|
||||||
|
- برای cellهای `—` (N/A) رنگ `var(--border)` خوب است
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۴. بازطراحی ClinicServicesPage
|
||||||
|
|
||||||
|
**هدف:** پنل بخشها بهتر + جدول آیتمها با badge فعال/غیرفعال.
|
||||||
|
|
||||||
|
**الف — پنل بخشها (ستون ۲۸۰px):**
|
||||||
|
- هر بخش یک item با:
|
||||||
|
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
|
||||||
|
- نام بخش bold
|
||||||
|
- badge «فعال» / «غیرفعال» بر اساس `s.active`
|
||||||
|
- دکمههای edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
|
||||||
|
- انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)`
|
||||||
|
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
|
||||||
|
|
||||||
|
**ب — پنل آیتمها (ستون 1fr):**
|
||||||
|
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتمها + دکمه «سرویس جدید»
|
||||||
|
- هر row آیتم:
|
||||||
|
- badge «فعال» / «غیرفعال» برای `item.active`
|
||||||
|
- قیمت با `formatRial()` و رنگ primary
|
||||||
|
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت
|
||||||
|
- Empty state وقتی `items.length === 0`:
|
||||||
|
```tsx
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
|
||||||
|
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
|
||||||
|
سرویسی در این بخش وجود ندارد
|
||||||
|
<br />
|
||||||
|
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**ج — State اولیه (هیچ بخشی انتخاب نشده):**
|
||||||
|
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
|
||||||
|
```tsx
|
||||||
|
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
|
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} />
|
||||||
|
<div>یک بخش انتخاب کنید تا سرویسهای آن را ببینید</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۵. بازطراحی SmsWalletPage
|
||||||
|
|
||||||
|
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضحتر + جدول تراکنشها با visual type indicator.
|
||||||
|
|
||||||
|
**الف — کارت موجودی:**
|
||||||
|
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
|
||||||
|
- رنگ متن: white
|
||||||
|
- موجودی را بزرگتر کن: `fontSize: 36, fontWeight: 800`
|
||||||
|
- زیر موجودی: نوار پیشرفت `.bar` که نشان میدهد چند پیامک باقیمانده (نسبت به یک سقف فرضی مثلاً ۵۰۰ پیامک یا بر اساس estimated_sms_count)
|
||||||
|
- badge status: اگر `estimated_sms_count < 10` → badge قرمز «موجودی کم»، اگر ≥ 10 → badge سبز «فعال»
|
||||||
|
- دکمه شارژ: white با primary text (برعکس از theme معمول)
|
||||||
|
|
||||||
|
**ب — کارت تنظیمات:**
|
||||||
|
- عنوان با divider پایین
|
||||||
|
- هر toggle را با یک card داخلی wrapper کن:
|
||||||
|
```tsx
|
||||||
|
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<input type="checkbox" ... />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
|
||||||
|
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
- input «چند ساعت قبل» را با border و padding واضحتر style کن
|
||||||
|
|
||||||
|
**ج — جدول تراکنشها:**
|
||||||
|
- ستون «نوع» را با یک آیکون بهبود بده:
|
||||||
|
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
|
||||||
|
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
|
||||||
|
- ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز
|
||||||
|
- header table با background خاکستری کمرنگ
|
||||||
|
|
||||||
|
**د — Modal شارژ:**
|
||||||
|
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
|
||||||
|
```tsx
|
||||||
|
{(['mellat', 'sep'] as const).map((gw) => (
|
||||||
|
<div
|
||||||
|
key={gw}
|
||||||
|
onClick={() => setGateway(gw)}
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: gateway === gw ? 'var(--primary-subtle)' : '',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
- پیشنمایش مبلغ: وقتی کاربر عدد میزند، زیر input بنویس «پرداخت X ریال از طریق Y»
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
1. **فقط فرانتاند** — هیچ endpoint، migration، یا تغییر backend لازم نیست
|
||||||
|
2. **CSS موجود را استفاده کن** — هیچ کتابخانه جدید اضافه نکن. inline style برای تنظیمات جزئی قابل قبول است
|
||||||
|
3. **DataTable component موجود** در `assets/admin/components/ui/DataTable.tsx` — برای جداول از آن استفاده کن (ترجیحاً بر inline table)
|
||||||
|
4. **RTL** رعایت شود — padding/margin/icon placement همه RTL-first باشد
|
||||||
|
5. **هر صفحه مستقل** پیادهسازی شود — نه refactor مشترک
|
||||||
|
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
|
||||||
|
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
|
||||||
|
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
|
||||||
|
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن
|
||||||
|
10. **`formatDate()` و `formatDateTime()`** — همه تاریخها شمسی نمایش داده شوند
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -129,84 +129,177 @@ export default function ClinicServicesPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader title="سرویسهای کلینیک" description="مدیریت بخشها و سرویسهای کلینیک" />
|
<PageHeader title="سرویسهای کلینیک" description="مدیریت بخشها و سرویسهای کلینیک" />
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr', gap: 16, alignItems: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
{/* ستون بخشها */}
|
{/* ستون بخشها */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
<b style={{ fontSize: 14 }}>بخشها</b>
|
<b style={{ fontSize: 14 }}>بخشها</b>
|
||||||
<button className="btn primary sm" onClick={() => { sectionForm.reset(); setSectionModal('create'); }}>
|
<button
|
||||||
<PlusIcon style={{ width: 14 }} />
|
className="btn primary sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
onClick={() => { sectionForm.reset(); setSectionModal('create'); }}
|
||||||
|
>
|
||||||
|
<PlusIcon style={{ width: 14 }} /> بخش جدید
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sectionsLoading ? (
|
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<div style={{ padding: '8px 0' }}>
|
||||||
) : sections.length === 0 ? (
|
{sectionsLoading ? (
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>بخشی ثبت نشده است</div>
|
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '12px 16px' }}>در حال بارگذاری...</div>
|
||||||
) : (
|
) : sections.length === 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
{sections.map((s) => (
|
<WrenchScrewdriverIcon style={{ width: 32, margin: '0 auto 10px', display: 'block', opacity: 0.4 }} />
|
||||||
|
<div style={{ fontSize: 13 }}>بخشی ثبت نشده است</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sections.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.uuid}
|
key={s.uuid}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '8px 10px', borderRadius: 6, cursor: 'pointer',
|
padding: '10px 14px', cursor: 'pointer',
|
||||||
background: selectedSection?.uuid === s.uuid ? 'var(--primary-subtle)' : 'transparent',
|
background: selectedSection?.uuid === s.uuid ? 'var(--primary-subtle)' : 'transparent',
|
||||||
border: selectedSection?.uuid === s.uuid ? '1px solid var(--primary)' : '1px solid transparent',
|
borderRight: selectedSection?.uuid === s.uuid ? '3px solid var(--primary)' : '3px solid transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
onClick={() => setSelectedSection(s)}
|
onClick={() => setSelectedSection(s)}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 13 }}>{s.name}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<WrenchScrewdriverIcon style={{
|
||||||
|
width: 16,
|
||||||
|
color: selectedSection?.uuid === s.uuid ? 'var(--primary)' : 'var(--text-3)',
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13.5, fontWeight: selectedSection?.uuid === s.uuid ? 600 : 400,
|
||||||
|
color: selectedSection?.uuid === s.uuid ? 'var(--primary)' : 'var(--text-1)',
|
||||||
|
}}>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="btn sm" onClick={() => openEditSection(s)}><PencilIcon style={{ width: 13 }} /></button>
|
<button
|
||||||
<button className="btn sm" onClick={() => setDeleteSection(s)}><TrashIcon style={{ width: 13 }} /></button>
|
className="btn sm"
|
||||||
|
style={{ opacity: 0.7 }}
|
||||||
|
onClick={() => openEditSection(s)}
|
||||||
|
title="ویرایش"
|
||||||
|
>
|
||||||
|
<PencilIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn sm"
|
||||||
|
style={{ opacity: 0.7 }}
|
||||||
|
onClick={() => setDeleteSection(s)}
|
||||||
|
title="حذف"
|
||||||
|
>
|
||||||
|
<TrashIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ستون آیتمها */}
|
{/* ستون آیتمها */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{!selectedSection ? (
|
{!selectedSection ? (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
<div style={{
|
||||||
یک بخش را از سمت راست انتخاب کنید
|
border: '2px dashed var(--border)', borderRadius: 10,
|
||||||
|
margin: 16, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)',
|
||||||
|
}}>
|
||||||
|
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 14px', display: 'block', opacity: 0.35 }} />
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-2)' }}>یک بخش انتخاب کنید</div>
|
||||||
|
<div style={{ fontSize: 13, marginTop: 4 }}>تا سرویسهای آن را مشاهده و مدیریت کنید</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
{/* toolbar بخش انتخابشده */}
|
||||||
<b style={{ fontSize: 14 }}>سرویسهای «{selectedSection.name}»</b>
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<WrenchScrewdriverIcon style={{ width: 16, color: 'var(--primary)' }} />
|
||||||
|
<b style={{ fontSize: 14 }}>{selectedSection.name}</b>
|
||||||
|
{!itemsLoading && (
|
||||||
|
<span className="badge gray" style={{ fontSize: 11 }}>{items.length} سرویس</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
|
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
|
||||||
>
|
>
|
||||||
<PlusIcon style={{ width: 14 }} /> سرویس جدید
|
<PlusIcon style={{ width: 14 }} /> سرویس جدید
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{itemsLoading ? (
|
{itemsLoading ? (
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '16px 16px' }}>در حال بارگذاری...</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>سرویسی ثبت نشده است</div>
|
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
|
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
|
||||||
|
<div style={{ fontWeight: 500, color: 'var(--text-2)', marginBottom: 4 }}>سرویسی در این بخش وجود ندارد</div>
|
||||||
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
|
||||||
|
>
|
||||||
|
افزودن سرویس
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ background: 'oklch(0.97 0.01 256)', borderBottom: '1px solid var(--border)' }}>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>نام سرویس</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>نام سرویس</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>قیمت</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>قیمت</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>پرسنل مسئول</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>پرسنل مسئول</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>عملیات</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>عملیات</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item) => (
|
{items.map((item, idx) => (
|
||||||
<tr key={item.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr
|
||||||
<td style={{ padding: '8px 4px' }}>{item.name}</td>
|
key={item.uuid}
|
||||||
<td style={{ padding: '8px 4px' }}>{formatRial(item.price_rials)}</td>
|
style={{
|
||||||
<td style={{ padding: '8px 4px' }}>{item.staff?.full_name ?? '—'}</td>
|
borderBottom: '1px solid var(--border)',
|
||||||
<td style={{ padding: '8px 4px' }}>
|
background: idx % 2 === 1 ? 'oklch(0.985 0.005 256)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '11px 16px', fontWeight: 500 }}>{item.name}</td>
|
||||||
|
<td style={{ padding: '11px 16px', color: 'var(--primary)', fontWeight: 600 }}>
|
||||||
|
{formatRial(item.price_rials)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '11px 16px' }}>
|
||||||
|
{item.staff ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(145deg, oklch(0.62 0.15 162), oklch(0.48 0.16 162))',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: '#fff', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{item.staff.full_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 13 }}>{item.staff.full_name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '11px 16px' }}>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button className="btn sm" onClick={() => openEditItem(item)}><PencilIcon style={{ width: 13 }} /></button>
|
<button className="btn sm" onClick={() => openEditItem(item)} title="ویرایش">
|
||||||
<button className="btn sm" onClick={() => setDeleteItem(item)}><TrashIcon style={{ width: 13 }} /></button>
|
<PencilIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm" onClick={() => setDeleteItem(item)} title="حذف">
|
||||||
|
<TrashIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon,
|
MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon,
|
||||||
|
FolderOpenIcon, UsersIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -30,6 +31,10 @@ const PAYMENT_LABELS: Record<string, string> = {
|
|||||||
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
|
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAYMENT_BADGE: Record<string, string> = {
|
||||||
|
cash: 'green', card: 'blue', insurance: 'purple', online: 'blue', pending: 'amber',
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_RECORDS: PatientRecord[] = [];
|
const EMPTY_RECORDS: PatientRecord[] = [];
|
||||||
const EMPTY_SESSIONS: PatientSession[] = [];
|
const EMPTY_SESSIONS: PatientSession[] = [];
|
||||||
|
|
||||||
@@ -139,13 +144,20 @@ export default function MyPatientsPage() {
|
|||||||
key: 'user',
|
key: 'user',
|
||||||
header: 'بیمار',
|
header: 'بیمار',
|
||||||
render: (r) => (
|
render: (r) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div className="avatar sm" style={{ background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))', flexShrink: 0 }}>
|
<div
|
||||||
|
className="avatar sm"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(r.user.fullName ?? '?').charAt(0)}
|
{(r.user.fullName ?? '?').charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.user.fullName ?? '—'}</div>
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.user.fullName ?? '—'}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 2 }}>
|
||||||
<PhoneIcon style={{ width: 11 }} />
|
<PhoneIcon style={{ width: 11 }} />
|
||||||
<span dir="ltr">{r.user.phone ?? '—'}</span>
|
<span dir="ltr">{r.user.phone ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,13 +165,24 @@ export default function MyPatientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
header: 'تاریخ ثبت',
|
||||||
|
render: (r) => (
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(r.created_at)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'uuid',
|
key: 'uuid',
|
||||||
header: 'عملیات',
|
header: '',
|
||||||
render: (r) => (
|
render: (r) => (
|
||||||
<button className="btn primary sm" onClick={() => { setSelectedRecord(r); setSessionPage(1); }}>
|
<button
|
||||||
پرونده
|
className="btn primary sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||||
|
onClick={() => { setSelectedRecord(r); setSessionPage(1); }}
|
||||||
|
>
|
||||||
|
<FolderOpenIcon style={{ width: 14 }} />
|
||||||
|
مشاهده پرونده
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -168,23 +191,47 @@ export default function MyPatientsPage() {
|
|||||||
if (!selectedRecord) {
|
if (!selectedRecord) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="پرونده بیماران" description="لیست بیماران و سوابق مراجعات" />
|
<PageHeader title="پرونده بیماران" description="مراجعهکنندگان ثبتشده شما" />
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
placeholder="جستجو بر اساس نام یا تلفن..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ padding: '12px 16px 0', display: 'flex', gap: 8 }}>
|
{records.length === 0 && !isLoading ? (
|
||||||
<div style={{ position: 'relative', flex: 1 }}>
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
|
||||||
<MagnifyingGlassIcon style={{ width: 16, position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
|
<UsersIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||||
<input
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
|
||||||
style={{ paddingRight: 32, width: '100%' }}
|
{search ? 'بیماری یافت نشد' : 'هنوز بیماری ثبت نشده است'}
|
||||||
value={search}
|
</div>
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
<div style={{ fontSize: 13 }}>
|
||||||
placeholder="جستجو بر اساس نام یا تلفن..."
|
{search ? 'عبارت جستجو را تغییر دهید' : 'پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد میشود'}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<DataTable columns={recordColumns} data={records} loading={isLoading} emptyMessage="بیماری ثبت نشده است" />
|
<>
|
||||||
<div style={{ padding: '0 16px 12px' }}>
|
<DataTable columns={recordColumns} data={records} loading={isLoading} emptyMessage="بیماری ثبت نشده است" />
|
||||||
<Pagination page={page} total={totalRec} limit={limit} onPageChange={setPage} />
|
<div style={{ padding: '0 16px 12px' }}>
|
||||||
</div>
|
<Pagination page={page} total={totalRec} limit={limit} onPageChange={setPage} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -200,19 +247,66 @@ export default function MyPatientsPage() {
|
|||||||
<button className="btn sm" onClick={() => setSelectedRecord(null)}>
|
<button className="btn sm" onClick={() => setSelectedRecord(null)}>
|
||||||
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
|
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
|
||||||
</button>
|
</button>
|
||||||
<button className="btn primary sm" onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}>
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}
|
||||||
|
>
|
||||||
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
|
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* بنر اطلاعات بیمار */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
|
||||||
|
borderRadius: 'var(--r)',
|
||||||
|
padding: '16px 20px',
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 20, fontWeight: 700, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{(selectedRecord.user.fullName ?? '?').charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 17 }}>{selectedRecord.user.fullName ?? '—'}</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.85, marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<PhoneIcon style={{ width: 13 }} />
|
||||||
|
<span dir="ltr">{selectedRecord.user.phone ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', background: 'rgba(255,255,255,0.15)', borderRadius: 10, padding: '8px 16px' }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800 }}>{totalSes}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ fontWeight: 600, padding: '12px 16px 8px' }}>مراجعات</div>
|
<div style={{ fontWeight: 600, padding: '14px 16px 10px', borderBottom: '1px solid var(--border)', fontSize: 14 }}>
|
||||||
|
تاریخچه مراجعات
|
||||||
|
</div>
|
||||||
{sessionsLoading ? (
|
{sessionsLoading ? (
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||||
) : sessions.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>مراجعهای ثبت نشده است</div>
|
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
|
<FolderOpenIcon style={{ width: 40, margin: '0 auto 12px', display: 'block', opacity: 0.4 }} />
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-2)' }}>مراجعهای ثبت نشده است</div>
|
||||||
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}
|
||||||
|
>
|
||||||
|
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sessions.map((s) => <SessionRow key={s.uuid} session={s} onEdit={setEditSession} />)
|
sessions.map((s) => <SessionRow key={s.uuid} session={s} onEdit={setEditSession} />)
|
||||||
)}
|
)}
|
||||||
@@ -226,7 +320,7 @@ export default function MyPatientsPage() {
|
|||||||
{/* Modal مراجعه جدید */}
|
{/* Modal مراجعه جدید */}
|
||||||
<Modal open={sessionModal} onClose={() => setSessionModal(false)} title="ثبت مراجعه جدید">
|
<Modal open={sessionModal} onClose={() => setSessionModal(false)} title="ثبت مراجعه جدید">
|
||||||
<form onSubmit={handleSubmitSession}>
|
<form onSubmit={handleSubmitSession}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>قیمت ویزیت (ریال)</label>
|
<label>قیمت ویزیت (ریال)</label>
|
||||||
@@ -255,8 +349,8 @@ export default function MyPatientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>افزودن سرویس</div>
|
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>افزودن خدمات</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={sectionOptions}
|
options={sectionOptions}
|
||||||
@@ -274,27 +368,38 @@ export default function MyPatientsPage() {
|
|||||||
isDisabled={!sectionUuid}
|
isDisabled={!sectionUuid}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn sm" onClick={handleAddService} disabled={!itemUuid}>
|
<button type="button" className="btn primary sm" onClick={handleAddService} disabled={!itemUuid}>
|
||||||
<PlusIcon style={{ width: 14 }} />
|
<PlusIcon style={{ width: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedServices.map((svc) => (
|
|
||||||
<div key={svc.service_item_uuid} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '4px 8px', background: 'var(--surface-2)', borderRadius: 4, marginBottom: 4 }}>
|
{selectedServices.length > 0 && (
|
||||||
<span>{svc.name}</span>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
{selectedServices.map((svc) => (
|
||||||
<span>{formatRial(svc.price_rials)}</span>
|
<div
|
||||||
<button type="button" style={{ fontSize: 11, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer' }}
|
key={svc.service_item_uuid}
|
||||||
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}>
|
style={{
|
||||||
حذف
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
</button>
|
background: 'var(--primary-subtle)', border: '1px solid oklch(0.85 0.06 256)',
|
||||||
</div>
|
borderRadius: 20, padding: '4px 10px', fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>{svc.name}</span>
|
||||||
|
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>{formatRial(svc.price_rials)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, fontSize: 14, lineHeight: 1 }}
|
||||||
|
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'var(--surface-2)', borderRadius: 8, padding: 12, fontSize: 13 }}>
|
<div style={{ background: 'var(--primary-subtle)', border: '1px solid oklch(0.88 0.05 256)', borderRadius: 10, padding: 14 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6, fontSize: 13, color: 'var(--text-3)' }}>
|
||||||
<span style={{ color: 'var(--text-3)' }}>جمع سرویسها</span>
|
<span>جمع خدمات</span>
|
||||||
<span>{formatRial(servicesTotal)}</span>
|
<span>{formatRial(servicesTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 15 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 15 }}>
|
||||||
@@ -325,34 +430,69 @@ export default function MyPatientsPage() {
|
|||||||
|
|
||||||
function SessionRow({ session, onEdit }: { session: PatientSession; onEdit: (s: PatientSession) => void }) {
|
function SessionRow({ session, onEdit }: { session: PatientSession; onEdit: (s: PatientSession) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? 'gray';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
<div style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', cursor: 'pointer' }}
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '12px 16px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: open ? 'var(--primary)' : 'var(--border)',
|
||||||
|
transition: 'background 0.15s', flexShrink: 0,
|
||||||
|
}} />
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDateTime(session.created_at)}</span>
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDateTime(session.created_at)}</span>
|
||||||
<span className={`badge ${session.payment_method === 'pending' ? 'amber' : 'green'}`}>
|
<span className={`badge ${badgeColor}`}>
|
||||||
{PAYMENT_LABELS[session.payment_method] ?? session.payment_method}
|
{PAYMENT_LABELS[session.payment_method] ?? session.payment_method}
|
||||||
</span>
|
</span>
|
||||||
<b style={{ fontSize: 14 }}>{formatRial(session.final_price_rials)}</b>
|
<b style={{ fontSize: 14, color: 'var(--primary)' }}>{formatRial(session.final_price_rials)}</b>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn sm" onClick={(e) => { e.stopPropagation(); onEdit(session); }}>
|
<button
|
||||||
|
className="btn sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit(session); }}
|
||||||
|
title="ویرایش مراجعه"
|
||||||
|
>
|
||||||
<PencilIcon style={{ width: 14 }} />
|
<PencilIcon style={{ width: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13 }}>
|
<div style={{ padding: '0 16px 14px' }}>
|
||||||
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>قیمت ویزیت</div><div>{formatRial(session.visit_price_rials)}</div></div>
|
<div style={{
|
||||||
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>تخفیف بیمه پایه</div><div>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div></div>
|
background: 'var(--surface)',
|
||||||
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>جمع سرویسها</div><div>{formatRial(session.services_total_rials)}</div></div>
|
border: '1px solid var(--border)',
|
||||||
{session.notes && (
|
borderRadius: 10,
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
padding: 14,
|
||||||
<div style={{ color: 'var(--text-3)', marginBottom: 2 }}>یادداشت</div>
|
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13,
|
||||||
<div>{session.notes}</div>
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>قیمت ویزیت</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{formatRial(session.visit_price_rials)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>تخفیف بیمه پایه</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>جمع خدمات</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{formatRial(session.services_total_rials)}</div>
|
||||||
|
</div>
|
||||||
|
{session.notes && (
|
||||||
|
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 2 }}>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>یادداشت</div>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>{session.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +530,11 @@ function EditSessionModal({
|
|||||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn primary" disabled={loading} onClick={() => session && onSave(session.uuid, { notes, payment_method: method })}>
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => session && onSave(session.uuid, { notes, payment_method: method })}
|
||||||
|
>
|
||||||
{loading ? 'در حال ذخیره...' : 'ذخیره'}
|
{loading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={onClose}>انصراف</button>
|
<button className="btn" onClick={onClose}>انصراف</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, IdentificationIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -10,6 +10,7 @@ import type { ApiResponse } from '../lib/api';
|
|||||||
import type { Secretary, SecretaryPermissions } from '../types';
|
import type { Secretary, SecretaryPermissions } from '../types';
|
||||||
import { formatDate, maskMobile } from '../lib/utils';
|
import { formatDate, maskMobile } from '../lib/utils';
|
||||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||||
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
@@ -83,27 +84,31 @@ function PermissionsMatrix({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="t">
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr style={{ background: 'oklch(0.97 0.01 256)' }}>
|
||||||
<th>بخش</th>
|
<th style={{ textAlign: 'right', padding: '10px 12px', fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>بخش</th>
|
||||||
{ACTION_HEADERS.map((h) => (
|
{ACTION_HEADERS.map((h) => (
|
||||||
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
|
<th key={h} style={{ textAlign: 'center', padding: '10px 8px', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
|
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section, idx) => {
|
||||||
const config = PERMISSION_LABELS[section];
|
const config = PERMISSION_LABELS[section];
|
||||||
const sectionPrm = permissions[section] as Record<string, boolean>;
|
const sectionPrm = permissions[section] as Record<string, boolean>;
|
||||||
return (
|
return (
|
||||||
<tr key={section}>
|
<tr key={section} style={{ background: idx % 2 === 0 ? 'transparent' : 'oklch(0.985 0.005 256)' }}>
|
||||||
<td><b>{config.label}</b></td>
|
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', fontWeight: 600 }}>
|
||||||
|
{config.label}
|
||||||
|
</td>
|
||||||
{ALL_ACTIONS.map((action) => {
|
{ALL_ACTIONS.map((action) => {
|
||||||
const ac = config.actions.find((a) => a.key === action);
|
const ac = config.actions.find((a) => a.key === action);
|
||||||
if (!ac) return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
if (!ac) return (
|
||||||
|
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)', color: 'var(--border)' }}>—</td>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<td key={action} style={{ textAlign: 'center' }}>
|
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={sectionPrm[action] ?? false}
|
checked={sectionPrm[action] ?? false}
|
||||||
@@ -188,6 +193,63 @@ export default function MySecretariesPage() {
|
|||||||
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns: Column<Secretary>[] = [
|
||||||
|
{
|
||||||
|
key: 'user_name',
|
||||||
|
header: 'منشی',
|
||||||
|
render: (s) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div
|
||||||
|
className="avatar sm"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))',
|
||||||
|
flexShrink: 0, fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(s.user_name ?? '?').charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.user_name}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mobile_number',
|
||||||
|
header: 'موبایل',
|
||||||
|
render: (s) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 13, color: 'var(--text-2)' }}>
|
||||||
|
<PhoneIcon style={{ width: 13 }} />
|
||||||
|
<span dir="ltr">{maskMobile(s.mobile_number)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_active',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (s) => <ActiveBadge active={s.is_active} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
header: 'تاریخ ثبت',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uuid',
|
||||||
|
header: 'عملیات',
|
||||||
|
render: (s) => (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش دسترسیها">
|
||||||
|
<PencilIcon style={{ width: 14 }} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm" onClick={() => setDeleteTarget(s)} title="غیرفعالسازی">
|
||||||
|
<TrashIcon style={{ width: 14 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -205,45 +267,22 @@ export default function MySecretariesPage() {
|
|||||||
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}>
|
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
پروفایل پزشک یافت نشد
|
پروفایل پزشک یافت نشد
|
||||||
</div>
|
</div>
|
||||||
) : isLoading ? (
|
|
||||||
<div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
|
||||||
) : secretaries.length === 0 ? (
|
|
||||||
<div className="card card-pad" style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
|
|
||||||
هنوز منشیای ثبت نشده است
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
{secretaries.length === 0 && !isLoading ? (
|
||||||
<thead>
|
<div style={{ textAlign: 'center', padding: '60px 24px' }}>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نام</th>
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز منشیای اضافه نشده</div>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>موبایل</th>
|
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>وضعیت</th>
|
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
</div>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||||
</tr>
|
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
||||||
</thead>
|
</button>
|
||||||
<tbody>
|
</div>
|
||||||
{secretaries.map((s, i) => (
|
) : (
|
||||||
<tr key={s.uuid} style={{ borderBottom: i < secretaries.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
<DataTable columns={columns} data={secretaries} loading={isLoading} emptyMessage="منشیای ثبت نشده است" />
|
||||||
<td style={{ padding: '10px 16px' }}><b>{s.user_name}</b></td>
|
)}
|
||||||
<td style={{ padding: '10px 16px', direction: 'ltr', textAlign: 'left' }}>{maskMobile(s.mobile_number)}</td>
|
|
||||||
<td style={{ padding: '10px 16px' }}><ActiveBadge active={s.is_active} /></td>
|
|
||||||
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</td>
|
|
||||||
<td style={{ padding: '10px 16px' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش دسترسیها">
|
|
||||||
<PencilIcon style={{ width: 14 }} />
|
|
||||||
</button>
|
|
||||||
<button className="btn sm" onClick={() => setDeleteTarget(s)} title="غیرفعالسازی">
|
|
||||||
<TrashIcon style={{ width: 14 }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { SmsWalletBalance, SmsWalletLog, SmsSettings } from '../types';
|
import type { SmsWalletBalance, SmsWalletLog, SmsSettings } from '../types';
|
||||||
@@ -46,6 +49,7 @@ export default function SmsWalletPage() {
|
|||||||
const settings = settingsData?.data;
|
const settings = settingsData?.data;
|
||||||
|
|
||||||
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
|
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
|
||||||
|
const watchAmount = chargeForm.watch('amount_rials');
|
||||||
|
|
||||||
const chargeMutation = useMutation({
|
const chargeMutation = useMutation({
|
||||||
mutationFn: ({ amount_rials }: ChargeForm) =>
|
mutationFn: ({ amount_rials }: ChargeForm) =>
|
||||||
@@ -67,26 +71,75 @@ export default function SmsWalletPage() {
|
|||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const smsCount = balance?.estimated_sms_count ?? 0;
|
||||||
|
const isLowBalance = smsCount < 10;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
|
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
{/* کارت موجودی */}
|
{/* کارت موجودی — gradient */}
|
||||||
<div className="card">
|
<div style={{
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 4 }}>موجودی کیف پول</div>
|
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 ? (
|
{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)}
|
{formatRial(balance?.balance_rials ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 16 }}>
|
<div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}>
|
||||||
معادل {formatNumber(balance?.estimated_sms_count ?? 0)} پیامک
|
معادل {formatNumber(smsCount)} پیامک
|
||||||
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
|
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -95,99 +148,167 @@ export default function SmsWalletPage() {
|
|||||||
|
|
||||||
{/* تنظیمات پیامک */}
|
{/* تنظیمات پیامک */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>تنظیمات ارسال</div>
|
<div style={{
|
||||||
{currentSettings ? (
|
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
|
}}>
|
||||||
<input
|
<DevicePhoneMobileIcon style={{ width: 16, color: 'var(--primary)' }} />
|
||||||
type="checkbox"
|
تنظیمات ارسال
|
||||||
checked={currentSettings.reminder_enabled}
|
</div>
|
||||||
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
|
<div style={{ padding: '16px' }}>
|
||||||
/>
|
{currentSettings ? (
|
||||||
یادآوری قبل از نوبت
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
</label>
|
{/* toggle یادآوری */}
|
||||||
{currentSettings.reminder_enabled && (
|
<div style={{
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
background: currentSettings.reminder_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
|
||||||
<label style={{ fontSize: 13 }}>چند ساعت قبل</label>
|
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
|
<input
|
||||||
type="number"
|
type="checkbox"
|
||||||
min={1}
|
checked={currentSettings.reminder_enabled}
|
||||||
max={72}
|
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
|
||||||
value={currentSettings.reminder_hours_before}
|
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
|
||||||
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
|
onClick={(e) => e.stopPropagation()}
|
||||||
dir="ltr"
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
|
||||||
|
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
|
{currentSettings.reminder_enabled && (
|
||||||
<input
|
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
|
||||||
type="checkbox"
|
<label style={{ fontSize: 13 }}>چند ساعت قبل از نوبت</label>
|
||||||
checked={currentSettings.post_visit_enabled}
|
<input
|
||||||
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
|
type="number" min={1} max={72}
|
||||||
/>
|
value={currentSettings.reminder_hours_before}
|
||||||
پیامک بعد از ویزیت
|
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
|
||||||
</label>
|
dir="ltr"
|
||||||
{currentSettings.post_visit_enabled && (
|
style={{ width: 90 }}
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
/>
|
||||||
<label style={{ fontSize: 13 }}>متن پیامک</label>
|
</div>
|
||||||
<textarea
|
)}
|
||||||
value={currentSettings.post_visit_text ?? ''}
|
|
||||||
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
|
{/* toggle بعد از ویزیت */}
|
||||||
rows={3}
|
<div style={{
|
||||||
placeholder="ممنون از مراجعه شما..."
|
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>
|
</div>
|
||||||
)}
|
|
||||||
<button
|
{currentSettings.post_visit_enabled && (
|
||||||
className="btn primary sm"
|
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
|
||||||
style={{ alignSelf: 'flex-start' }}
|
<label style={{ fontSize: 13 }}>متن پیامک</label>
|
||||||
disabled={saveMutation.isPending}
|
<textarea
|
||||||
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
value={currentSettings.post_visit_text ?? ''}
|
||||||
>
|
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
|
||||||
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
rows={3}
|
||||||
</button>
|
placeholder="ممنون از مراجعه شما..."
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</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>
|
</div>
|
||||||
|
|
||||||
{/* تاریخچه تراکنشها */}
|
{/* تاریخچه تراکنشها */}
|
||||||
<div className="card">
|
<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 ? (
|
{logsLoading ? (
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '16px' }}>در حال بارگذاری...</div>
|
||||||
) : logs.length === 0 ? (
|
) : 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 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ background: 'oklch(0.97 0.01 256)', borderBottom: '1px solid var(--border)' }}>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>نوع</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>نوع</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>مبلغ</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>مبلغ</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>توضیحات</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>توضیحات</th>
|
||||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>تاریخ</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>تاریخ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{logs.map((log) => (
|
{logs.map((log, idx) => (
|
||||||
<tr key={log.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr
|
||||||
<td style={{ padding: '8px 4px' }}>
|
key={log.uuid}
|
||||||
<span className={`badge ${log.type === 'credit' ? 'green' : 'red'}`}>
|
style={{
|
||||||
{log.type === 'credit' ? 'شارژ' : 'کسر'}
|
borderBottom: '1px solid var(--border)',
|
||||||
</span>
|
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>
|
||||||
<td style={{ padding: '8px 4px' }}>{formatRial(log.amount_rials)}</td>
|
<td style={{
|
||||||
<td style={{ padding: '8px 4px', color: 'var(--text-2)' }}>{log.description}</td>
|
padding: '11px 16px',
|
||||||
<td style={{ padding: '8px 4px', color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</td>
|
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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ padding: '12px 16px' }}>
|
||||||
<Pagination page={logPage} total={total} limit={20} onPageChange={setLogPage} />
|
<Pagination page={logPage} total={total} limit={20} onPageChange={setLogPage} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -197,26 +318,57 @@ export default function SmsWalletPage() {
|
|||||||
{/* Modal شارژ */}
|
{/* Modal شارژ */}
|
||||||
<Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک">
|
<Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک">
|
||||||
<form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}>
|
<form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
{/* انتخاب درگاه — radio cards */}
|
||||||
{(['mellat', 'sep'] as const).map((gw) => (
|
<div>
|
||||||
<button
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>انتخاب درگاه پرداخت</div>
|
||||||
key={gw}
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
type="button"
|
{(['mellat', 'sep'] as const).map((gw) => (
|
||||||
className={`btn ${gateway === gw ? 'primary' : ''}`}
|
<div
|
||||||
onClick={() => setGateway(gw)}
|
key={gw}
|
||||||
>
|
onClick={() => setGateway(gw)}
|
||||||
{gw === 'mellat' ? 'ملت' : 'سپ'}
|
style={{
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>مبلغ (ریال)</label>
|
<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 && (
|
{chargeForm.formState.errors.amount_rials && (
|
||||||
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
|
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button type="submit" className="btn primary" disabled={chargeMutation.isPending}>
|
<button type="submit" className="btn primary" disabled={chargeMutation.isPending}>
|
||||||
{chargeMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}
|
{chargeMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { PencilIcon, EyeIcon, EyeSlashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
import { PencilIcon, EyeIcon, EyeSlashIcon, PlusIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -38,6 +38,9 @@ export default function StaffPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const staff = data?.data ?? EMPTY;
|
const staff = data?.data ?? EMPTY;
|
||||||
|
const total = staff.length;
|
||||||
|
const active = staff.filter((s) => s.active).length;
|
||||||
|
const inactive = total - active;
|
||||||
|
|
||||||
const createForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
|
const createForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
|
||||||
const editForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
|
const editForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
|
||||||
@@ -86,10 +89,36 @@ export default function StaffPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<ClinicStaff>[] = [
|
const columns: Column<ClinicStaff>[] = [
|
||||||
{ key: 'full_name', header: 'نام و نام خانوادگی' },
|
{
|
||||||
{ key: 'job_title', header: 'سمت', render: (s) => s.job_title ?? '—' },
|
key: 'full_name',
|
||||||
{ key: 'phone', header: 'تلفن', render: (s) => s.phone ?? '—' },
|
header: 'پرسنل',
|
||||||
{ key: 'national_code', header: 'کد ملی', render: (s) => s.national_code ?? '—' },
|
render: (s) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.full_name}</div>
|
||||||
|
{s.job_title && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{s.job_title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'phone',
|
||||||
|
header: 'تلفن',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ fontSize: 13, direction: 'ltr', display: 'inline-block' }}>
|
||||||
|
{s.phone ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'national_code',
|
||||||
|
header: 'کد ملی',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ fontSize: 13, direction: 'ltr', display: 'inline-block' }}>
|
||||||
|
{s.national_code ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'active',
|
key: 'active',
|
||||||
header: 'وضعیت',
|
header: 'وضعیت',
|
||||||
@@ -98,7 +127,9 @@ export default function StaffPage() {
|
|||||||
{
|
{
|
||||||
key: 'created_at',
|
key: 'created_at',
|
||||||
header: 'تاریخ ثبت',
|
header: 'تاریخ ثبت',
|
||||||
render: (s) => formatDate(s.created_at),
|
render: (s) => (
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(s.created_at)}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'uuid',
|
key: 'uuid',
|
||||||
@@ -135,8 +166,37 @@ export default function StaffPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* KPI bar */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 16, gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||||
|
<div className="card card-pad" style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--text-1)' }}>{total}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>کل پرسنل</div>
|
||||||
|
</div>
|
||||||
|
<div className="card card-pad" style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'oklch(0.55 0.16 162)' }}>{active}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>فعال</div>
|
||||||
|
</div>
|
||||||
|
<div className="card card-pad" style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'oklch(0.55 0.18 25)' }}>{inactive}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>غیرفعال</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<DataTable columns={columns} data={staff} loading={isLoading} />
|
{staff.length === 0 && !isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
|
||||||
|
<UserGroupIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز پرسنلی ثبت نشده</div>
|
||||||
|
<div style={{ fontSize: 13, marginBottom: 20 }}>اولین عضو تیم خود را اضافه کنید</div>
|
||||||
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={staff} loading={isLoading} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal ایجاد */}
|
{/* Modal ایجاد */}
|
||||||
@@ -187,22 +247,26 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
|
|||||||
const { register, formState: { errors } } = form;
|
const { register, formState: { errors } } = form;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="field">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<label>نام و نام خانوادگی *</label>
|
<div className="field">
|
||||||
<input {...register('full_name')} placeholder="علی رضایی" />
|
<label>نام و نام خانوادگی *</label>
|
||||||
{errors.full_name && <span className="field-error">{errors.full_name.message}</span>}
|
<input {...register('full_name')} placeholder="علی رضایی" />
|
||||||
|
{errors.full_name && <span className="field-error">{errors.full_name.message}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>سمت</label>
|
||||||
|
<input {...register('job_title')} placeholder="پرستار" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<label>سمت</label>
|
<div className="field">
|
||||||
<input {...register('job_title')} placeholder="پرستار" />
|
<label>تلفن</label>
|
||||||
</div>
|
<input {...register('phone')} placeholder="09121234567" dir="ltr" />
|
||||||
<div className="field">
|
</div>
|
||||||
<label>تلفن</label>
|
<div className="field">
|
||||||
<input {...register('phone')} placeholder="09121234567" dir="ltr" />
|
<label>کد ملی</label>
|
||||||
</div>
|
<input {...register('national_code')} placeholder="0012345678" dir="ltr" />
|
||||||
<div className="field">
|
</div>
|
||||||
<label>کد ملی</label>
|
|
||||||
<input {...register('national_code')} placeholder="0012345678" dir="ltr" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>آدرس</label>
|
<label>آدرس</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user