diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index b27d8a46..a59402e2 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon, ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon, - EllipsisHorizontalIcon, CheckCircleIcon, ClockIcon, + EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon, } from '@heroicons/react/24/outline'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -29,6 +29,7 @@ const itemSchema = z.object({ staff_uuid: z.string().optional(), insurance_covered: z.boolean().optional(), insurance_price_rials: z.coerce.number().min(0).optional(), + duration_minutes: z.coerce.number().min(0).optional(), }); type SectionForm = z.infer; type ItemForm = z.infer; @@ -143,6 +144,16 @@ function ClinicServicesPageInner() { onError: (e: any) => { toast.error(e.message); setDeleteSection(null); }, }); + const toggleSection = useMutation({ + mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => + api.patch(`/api/v1/service-section/${uuid}`, { active }), + onSuccess: (_d, v) => { + qc.invalidateQueries({ queryKey: ['service-sections'] }); + toast.success(v.active ? 'بخش فعال شد' : 'بخش غیرفعال شد'); + }, + onError: (e: any) => toast.error(e.message), + }); + const createItem = useMutation({ mutationFn: (body: ItemForm & { section_uuid: string }) => api.post('/api/v1/service-item', { ...body, @@ -187,12 +198,13 @@ function ClinicServicesPageInner() { staff_uuid: item.staff?.uuid ?? '', insurance_covered: item.insurance_covered ?? false, insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0), + duration_minutes: item.duration_minutes ?? undefined, }); setItemModal(item); }; const openCreateItem = () => { - itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 }); + itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined }); setItemModal('create'); }; @@ -200,242 +212,172 @@ function ClinicServicesPageInner() { <> -
- {/* ───────── ستون بخش‌ها ───────── */} -
-
- بخش‌ها -
-
- {sectionsLoading ? ( -
در حال بارگذاری...
- ) : sections.length === 0 ? ( -
- -
بخشی ثبت نشده است
+ {sectionsLoading ? ( +
در حال بارگذاری...
+ ) : sections.length === 0 ? ( +
+ +
بخشی ثبت نشده است
+ +
+ ) : ( +
+ {sections.map((s) => ( +
setSelectedSection(s)} + style={{ + cursor: 'pointer', background: 'var(--surface)', borderRadius: 8, + boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)', + border: '1px solid var(--border)', padding: '18px 14px', + opacity: s.active ? 1 : 0.62, + }} + > +
{s.name}
+
e.stopPropagation()}> + + +
+
+ ))} +
+ )} + + ) : ( + /* ═════════ نمای سرویس‌های یک بخش ═════════ */ + <> +
+
+ +
+ setSelectedSection(null)}>بخش‌ها + + {selectedSection.name}
- ) : ( - sections.map((s) => { - const isActive = selectedSection?.uuid === s.uuid; +
+
+
+ + setSearch(e.target.value)} style={{ width: 170, paddingInlineStart: 30, height: 36 }} /> +
+ + +
+
+ + {itemsLoading ? ( +
در حال بارگذاری...
+ ) : items.length === 0 ? ( +
+ +
+ {allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'} +
+ {allItems.length === 0 && ( + + )} +
+ ) : ( +
+ {items.map((item) => { + const menuItemStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 8, width: '100%', + padding: '8px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', + background: 'transparent', color: 'var(--text-2)', fontSize: 13, + fontFamily: 'inherit', textAlign: 'start', whiteSpace: 'nowrap', + }; + const chipStyle: React.CSSProperties = { + fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-2)', + border: '1px solid var(--border)', borderRadius: 999, padding: '3px 10px', whiteSpace: 'nowrap', + }; return (
setSelectedSection(s)} > -
- - - {s.name} - + {/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */} +
+ +
+ + {item.active ? 'فعال' : 'غیرفعال'} + + {item.name} +
-
e.stopPropagation()}> - - + {menuOpen === item.uuid && ( + <> +
setMenuOpen(null)} /> +
+ + + + +
+ + )} + + + +
+ زمان متوسط: + {item.duration_minutes + ? {Number(item.duration_minutes).toLocaleString('fa-IR')} دقیقه + : } +
+ + {item.insurance_covered && item.insurance_price_rials != null && ( + + )} + +
+ پرسنل: +
+ {item.staff + ? {item.staff.full_name} + : } +
); - }) - )} -
-
- - {/* ───────── ستون سرویس‌ها ───────── */} -
- {!selectedSection ? ( -
- -
یک بخش انتخاب کنید
-
تا سرویس‌های آن را مشاهده و مدیریت کنید
+ })}
- ) : ( - <> -
-
- {selectedSection.name} - {!itemsLoading && ( - - {activeCount} فعال{allItems.length > activeCount ? ` / ${allItems.length}` : ''} - - )} -
- -
-
- - setSearch(e.target.value)} - style={{ width: 180, paddingInlineStart: 30, height: 34 }} - /> -
- - -
-
- - {itemsLoading ? ( -
در حال بارگذاری...
- ) : items.length === 0 ? ( -
- -
- {allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'} -
- {allItems.length === 0 && ( - - )} -
- ) : ( -
- {items.map((item) => { - const done = item.active; - const menuItemStyle: React.CSSProperties = { - display: 'flex', alignItems: 'center', gap: 8, width: '100%', - padding: '8px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', - background: 'transparent', color: 'var(--text-2)', fontSize: 13, - fontFamily: 'inherit', textAlign: 'start', whiteSpace: 'nowrap', - }; - return ( -
- {/* ⋮ منوی عملیات — چپ‌بالا (مطابق فیگما) */} - - {menuOpen === item.uuid && ( - <> -
setMenuOpen(null)} /> -
- - - - -
- - )} - - {/* هدر: آیکن وضعیت (راست) + نام و بخش */} -
-
- {done ? : } -
-
-
{item.name}
- {selectedSection && ( -
{selectedSection.name}
- )} -
-
- -
- - - - -
- - - {item.insurance_covered && item.insurance_price_rials != null && ( - - )} - - -
- ); - })} -
- )} - )} -
-
+ + )} {/* ───────── Modal بخش ───────── */}
+ +
+
+ +
+ +
+
+
{/* بیمه */} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 822dc393..7a04fdb0 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -431,6 +431,7 @@ export interface ServiceItem { active: boolean; insurance_covered?: boolean; insurance_price_rials?: number | null; + duration_minutes?: number | null; } export interface SmsWalletBalance { diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index 2b646983..c7e85790 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -90,6 +90,9 @@ "name": "رادیوگرافی مستقیم", "price_rials": 500000, "active": true, + "insurance_covered": false, + "insurance_price_rials": null, + "duration_minutes": 50, "created_at": 1718000000, "updated_at": 1718000000 } @@ -113,7 +116,8 @@ "price_rials": 500000, "staff_uuid": "...", "insurance_covered": true, - "insurance_price_rials": 200000 + "insurance_price_rials": 200000, + "duration_minutes": 50 } ``` @@ -121,10 +125,11 @@ |------|-----|--------| | section_uuid | UUID | ✅ | | name | string | ✅ | -| price_rials | integer | ❌ (پیش‌فرض 0) | -| staff_uuid | UUID | ❌ | +| price_rials | integer | ❌ (پیش‌فرض 0) — «قیمت پایه» | +| staff_uuid | UUID | ❌ — پرسنل مسئول | | insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود | | insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه | +| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) | **Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`) @@ -143,10 +148,13 @@ "staff_uuid": null, "active": false, "insurance_covered": true, - "insurance_price_rials": 250000 + "insurance_price_rials": 250000, + "duration_minutes": 30 } ``` +> فیلد `duration_minutes` (زمان متوسط، دقیقه) در پاسخِ `toArray` و در ساخت/ویرایش پشتیبانی می‌شود؛ `""`/`null` آن را پاک می‌کند. + > `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربط‌دادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال می‌شود. --- diff --git a/migrations/Version20260713050014.php b/migrations/Version20260713050014.php new file mode 100644 index 00000000..d7832d4c --- /dev/null +++ b/migrations/Version20260713050014.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE doctor_claim_requests RENAME INDEX uniq_claim_uuid TO UNIQ_352BEA22D17F50A6'); + $this->addSql('ALTER TABLE doctor_claim_requests RENAME INDEX idx_claim_user TO IDX_352BEA22A76ED395'); + $this->addSql('ALTER TABLE representations CHANGE is_global is_global TINYINT NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctor_claim_requests RENAME INDEX idx_352bea22a76ed395 TO IDX_claim_user'); + $this->addSql('ALTER TABLE doctor_claim_requests RENAME INDEX uniq_352bea22d17f50a6 TO UNIQ_claim_uuid'); + $this->addSql('ALTER TABLE representations CHANGE is_global is_global TINYINT DEFAULT 0 NOT NULL'); + } +} diff --git a/migrations/Version20260713050114.php b/migrations/Version20260713050114.php new file mode 100644 index 00000000..9f742dc5 --- /dev/null +++ b/migrations/Version20260713050114.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE service_items ADD duration_minutes INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE service_items DROP duration_minutes'); + } +} diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 16a42c72..ec7abc8f 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -172,6 +172,10 @@ class ClinicServiceController extends BaseController if (array_key_exists('insurance_price_rials', $data)) { $item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null); } + if (array_key_exists('duration_minutes', $data)) { + $dm = $data['duration_minutes']; + $item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm); + } $this->itemRepo->save($item); @@ -213,6 +217,10 @@ class ClinicServiceController extends BaseController if (array_key_exists('insurance_price_rials', $data)) { $item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null); } + if (array_key_exists('duration_minutes', $data)) { + $dm = $data['duration_minutes']; + $item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm); + } $this->itemRepo->save($item); diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 21505c79..169880f5 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -42,6 +42,9 @@ class ServiceItem #[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)] private ?int $insurancePriceRials = null; + #[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)] + private ?int $durationMinutes = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -67,6 +70,7 @@ class ServiceItem public function isActive(): bool { return $this->active; } public function isInsuranceCovered(): bool { return $this->insuranceCovered; } public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; } + public function getDurationMinutes(): ?int { return $this->durationMinutes; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -76,6 +80,7 @@ class ServiceItem public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; } public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; } public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; } + public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; } public function toArray(): array { @@ -92,6 +97,7 @@ class ServiceItem 'active' => $this->active, 'insurance_covered' => $this->insuranceCovered, 'insurance_price_rials' => $this->insurancePriceRials, + 'duration_minutes' => $this->durationMinutes, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ];