feat: implement filtered and paginated appointment retrieval for doctors
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: redesign-page
|
||||
description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرینشات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحرافها از دیزاینسیستم، و بازنویسی صفحه با کامپوننتها و توکنهای موجود. استفاده کن وقتی کاربر یک URL از /admin میدهد و میگوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
|
||||
---
|
||||
|
||||
# بازطراحی صفحه پنل ادمین ClinicPro
|
||||
|
||||
پنل ادمین یک SPA کلاینتساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
|
||||
یعنی `curl` و فلگ `--screenshot` کروم به درد نمیخورند: هر دو روی فرم لاگین مینشینند،
|
||||
چون توکن JWT در `localStorage['clinicpro-auth']` است.
|
||||
|
||||
درایور این skill آن کار را انجام میدهد: با API لاگین میکند، `localStorage` را seed
|
||||
میکند، بعد ناوبری و اسکرینشات میگیرد — با CDP روی `WebSocket` نیتیو Node 22،
|
||||
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
|
||||
|
||||
مسیرها نسبت به `clinicpro/` هستند.
|
||||
|
||||
## پیشنیازها
|
||||
|
||||
هیچ نصبی لازم نیست. فقط این دو:
|
||||
|
||||
```bash
|
||||
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
|
||||
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
```
|
||||
|
||||
کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
|
||||
|
||||
## گردش کار
|
||||
|
||||
### ۱. اسکرینشات صفحه فعلی
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot \
|
||||
"https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
|
||||
```
|
||||
|
||||
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
|
||||
یعنی حدس زدن.
|
||||
|
||||
فلگها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلیثانیه صبر برای رندر)،
|
||||
`--full` (کل صفحه، نه فقط ویوپورت).
|
||||
|
||||
موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot \
|
||||
"https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
|
||||
```
|
||||
|
||||
### ۲. نگاشت URL به سورس + آدیت
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs inspect \
|
||||
"https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
|
||||
```
|
||||
|
||||
خروجی واقعی:
|
||||
|
||||
```
|
||||
route clinics/:uuid
|
||||
component ClinicDetailPage
|
||||
file assets/admin/pages/ClinicDetailPage.tsx
|
||||
components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
|
||||
lines 1035
|
||||
|
||||
AUDIT
|
||||
assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
|
||||
```
|
||||
|
||||
روی هر فایل دلخواه هم مستقیم:
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
|
||||
```
|
||||
|
||||
### ۳. قبل از نوشتن کد، دیزاینسیستم را بخوان
|
||||
|
||||
**منبع حقیقتِ توکنها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
|
||||
(آن سند قدیمی و پالت بنفشش با کد شیپشده نمیخواند).
|
||||
|
||||
```bash
|
||||
sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکنها
|
||||
ls assets/admin/components/ui/ # کامپوننتهای آماده
|
||||
```
|
||||
|
||||
قانون: **اول کامپوننت موجود، بعد توسعهاش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
|
||||
|
||||
### ۴. بازنویسی، سپس مقایسه
|
||||
|
||||
بعد از ادیت، دوباره اسکرینشات بگیر و با `before.png` مقایسه کن:
|
||||
|
||||
```bash
|
||||
yarn dev # یا: yarn watch
|
||||
node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
|
||||
```
|
||||
|
||||
### ۵. تست + تایپچک (بدون این، تسک تمام نیست)
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npx vitest run assets/admin/pages/<YourPage>.test.tsx
|
||||
```
|
||||
|
||||
توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
|
||||
`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یکبار `npx vitest run`
|
||||
بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت میاندازی.
|
||||
|
||||
## چکلیست بازطراحی
|
||||
|
||||
درایور موارد گرپشدنی را میگیرد؛ اینها را باید خودت با چشم ببینی:
|
||||
|
||||
- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
|
||||
*داخلش* مینشیند. اگر `<label>` داخل `.field` بگذاری، لیبل کنار اینپوت میچسبد؛ و اگر
|
||||
`SearchableSelect` داخلش بگذاری، دو باکس تودرتو میشود. برای «لیبل بالای فیلد» از
|
||||
`.field-block` استفاده کن.
|
||||
- **`className="btn"` بدون واریانت** بیرنگ و بدون بوردر رندر میشود — عملاً نامرئی.
|
||||
همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
|
||||
- **دکمههای فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
|
||||
- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را میگیرد.
|
||||
- **سلسلهمراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
|
||||
- **RTL/جلالی** — رشتههای جدید فارسی، تاریخها جلالی، اعداد با `formatNumber`/`formatRial`.
|
||||
- **دارکمود** — چون توکن استفاده میکنی خودکار درست است؛ هگز هاردکد آن را میشکند.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ریدایرکت خاموش نقشها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بیصدا به
|
||||
`/admin/dashboard` میبرد. یعنی یک اسکرینشات کاملاً سالم از **صفحهٔ اشتباه** میگیری.
|
||||
درایور مسیر نهایی را با مسیر درخواستی مقایسه میکند و هشدار میدهد:
|
||||
|
||||
```
|
||||
⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
|
||||
```
|
||||
|
||||
کاربر پیشفرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمیشوند.
|
||||
برای آنها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
|
||||
|
||||
- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
|
||||
`Test@1234` را مستند میکند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
|
||||
ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را مینویسد — اول بپرس).
|
||||
|
||||
- **مودال نصب PWA جلوی صفحه را میگیرد.** درایور `localStorage['pwa-dismissed']='1'` را
|
||||
seed میکند. اگر با کروم خام اسکرینشات بگیری، این مودال وسط تصویر است.
|
||||
|
||||
- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
|
||||
توکن میدهد؛ درایور به همین تکیه میکند. اگر روی محیطی که کپچا را اجبار میکند اجرا شود، میشکند.
|
||||
|
||||
- **سرت ddev را Node رد میکند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
|
||||
هاستهای `*.ddev.site` / `localhost` تأیید TLS را خاموش میکند، نه برای هر مبدأ.
|
||||
|
||||
- **صفحهٔ نوبتها خودش اسکرول میشود** به ساعت جاری، پس ویوپورت وسط تایملاین میافتد.
|
||||
برای دیدن هدر از `--full` استفاده کن.
|
||||
|
||||
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` میدهد** — از قبل وجود دارد و جلوی
|
||||
کامپایل JS/TS را نمیگیرد. خطاهای TypeScript همچنان در خروجی `tsc` میآیند.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| علامت | علت / راهحل |
|
||||
|---|---|
|
||||
| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
|
||||
| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
|
||||
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
|
||||
| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
|
||||
| اسکرینشات تغییرات را نشان نمیدهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
|
||||
@@ -3,11 +3,14 @@
|
||||
* `src/components/dashboard/list/` (CustomTable + DetailT + Status).
|
||||
*
|
||||
* Source data was static mock; here it is fed by the real dashboard API
|
||||
* (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`). Read-only —
|
||||
* status display/editing lives on the appointments page, not the dashboard.
|
||||
* (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`) or, when a
|
||||
* `queryKey` is supplied, by the filtered doctor list endpoint. Status is
|
||||
* editable in place only when the row carries a `version` (optimistic lock)
|
||||
* and the parent passes the query key to invalidate.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
|
||||
|
||||
export interface ApptRow {
|
||||
uuid: string;
|
||||
@@ -18,6 +21,8 @@ export interface ApptRow {
|
||||
slot_start: number;
|
||||
slot_end?: number | null;
|
||||
status: string;
|
||||
/** لازم برای قفل خوشبینانهٔ PATCH وضعیت؛ نبودنش یعنی وضعیت فقطخواندنی است. */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,16 +40,24 @@ function formatTime(ts?: number | null): string {
|
||||
return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
|
||||
}
|
||||
|
||||
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'عملیات'];
|
||||
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
||||
|
||||
export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loading?: boolean }) {
|
||||
interface Props {
|
||||
rows: ApptRow[];
|
||||
loading?: boolean;
|
||||
/** کلید کوئری برای invalidate پس از تغییر وضعیت. بدون آن وضعیت فقط نمایش داده میشود. */
|
||||
queryKey?: unknown[];
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Props) {
|
||||
if (loading) {
|
||||
return <div className="skeleton h-[180px] rounded-[8px]" />;
|
||||
}
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<p className="text-center py-[32px] text-[13.5px] text-[#7E7E7E] dark:text-[#A1A1A1]">
|
||||
نوبتی برای امروز ثبت نشده
|
||||
{emptyText ?? 'نوبتی برای امروز ثبت نشده'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -642,10 +642,44 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
|
||||
// بدون هیچ پارامتر فیلتر/صفحهبندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ
|
||||
// میشود تا کلاینتهای موجود نشکنند. با هر فیلتری پاسخ صفحهبندیشده میآید.
|
||||
$filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit'];
|
||||
$isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k));
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
if (!$isFiltered) {
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
$statuses = $request->query->all('statuses');
|
||||
if ($statuses === [] && $request->query->get('status')) {
|
||||
$statuses = [$request->query->get('status')];
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->appointmentRepo->searchByDoctor(
|
||||
doctor: $doctor,
|
||||
statuses: array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')),
|
||||
clinic: $scopeClinic,
|
||||
from: $request->query->has('from') ? (int) $request->query->get('from') : null,
|
||||
to: $request->query->has('to') ? (int) $request->query->get('to') : null,
|
||||
query: $request->query->get('q'),
|
||||
serviceUuid: $request->query->get('service_uuid'),
|
||||
page: $page,
|
||||
limit: $limit,
|
||||
);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Appointment $a) => $a->toArray(), $result['items']),
|
||||
$result['total'],
|
||||
$page,
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -182,6 +182,65 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findBy($criteria, ['slotStart' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtered + paginated appointment list for one doctor — backs the doctor
|
||||
* dashboard filter bar. Kept separate from findByDoctor() so existing
|
||||
* unfiltered callers keep their plain-array contract.
|
||||
*
|
||||
* @param string[] $statuses empty = no status restriction
|
||||
* @return array{items: Appointment[], total: int}
|
||||
*/
|
||||
public function searchByDoctor(
|
||||
Doctor $doctor,
|
||||
array $statuses = [],
|
||||
?Clinic $clinic = null,
|
||||
?int $from = null,
|
||||
?int $to = null,
|
||||
?string $query = null,
|
||||
?string $serviceUuid = null,
|
||||
int $page = 1,
|
||||
int $limit = 20,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->join('a.user', 'u')
|
||||
->where('a.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor);
|
||||
|
||||
if ($statuses !== []) {
|
||||
$qb->andWhere('a.status IN (:statuses)')->setParameter('statuses', $statuses);
|
||||
}
|
||||
// همان محدودسازی محیط که findByDoctor دارد: نوبت مطب شخصی به کلینیک نشت نکند.
|
||||
if ($clinic !== null) {
|
||||
$qb->andWhere('a.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
if ($from !== null) {
|
||||
$qb->andWhere('a.slotStart >= :from')->setParameter('from', $from);
|
||||
}
|
||||
if ($to !== null) {
|
||||
$qb->andWhere('a.slotStart <= :to')->setParameter('to', $to);
|
||||
}
|
||||
if ($serviceUuid !== null && $serviceUuid !== '') {
|
||||
$qb->join('a.serviceItem', 'si')
|
||||
->andWhere('si.uuid = :serviceUuid')
|
||||
->setParameter('serviceUuid', $serviceUuid);
|
||||
}
|
||||
// نام/موبایل هم روی فیلدهای خودِ نوبت ذخیره میشود و هم روی کاربر؛ هر دو جستوجو میشوند.
|
||||
if ($query !== null && trim($query) !== '') {
|
||||
$qb->andWhere('a.patientName LIKE :q OR a.patientMobile LIKE :q OR u.realName LIKE :q OR u.mobileNumber LIKE :q')
|
||||
->setParameter('q', '%' . trim($query) . '%');
|
||||
}
|
||||
|
||||
$total = (int) (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->orderBy('a.slotStart', 'ASC')
|
||||
->setFirstResult(max(0, ($page - 1) * $limit))
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتهای یک بیمار در یک کلینیک — بر پایهٔ خودِ محیطِ ثبتشدهٔ نوبت، تا غیرفعال
|
||||
* شدنِ بعدیِ پزشک تاریخچه را از پروندهٔ کلینیک حذف نکند.
|
||||
|
||||
Reference in New Issue
Block a user