From 17f41117f11e6abc14b4d0da38e15901283aebbe Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 24 Jun 2026 04:54:56 +0330 Subject: [PATCH] feat(tariffs): automate current year tariff registration and sync service price --- .../admin/components/ServiceTariffModal.tsx | 142 +++++++++++++----- docs/api/clinic-services.md | 4 + .../Controller/ClinicServiceController.php | 17 ++- 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/assets/admin/components/ServiceTariffModal.tsx b/assets/admin/components/ServiceTariffModal.tsx index 61235a73..3893b9fe 100644 --- a/assets/admin/components/ServiceTariffModal.tsx +++ b/assets/admin/components/ServiceTariffModal.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import { formatRial, formatNumber } from '../lib/utils'; import Modal from './ui/Modal'; +import PriceInput from './ui/PriceInput'; +import SearchableSelect from './ui/SearchableSelect'; import type { ServiceItem } from '../types'; interface TariffRow { @@ -21,8 +24,10 @@ interface TariffResponse { export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) { const qc = useQueryClient(); - const [year, setYear] = useState(''); - const [price, setPrice] = useState(''); + const [year, setYear] = useState(''); + const [price, setPrice] = useState(0); + const [editYear, setEditYear] = useState(null); + const [editPrice, setEditPrice] = useState(0); const { data, isLoading } = useQuery<{ data: TariffResponse }>({ queryKey: ['service-tariffs', item?.uuid], @@ -31,61 +36,128 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte }); const resp = (data as any)?.data as TariffResponse | undefined; - const tariffs = resp?.data ?? []; + const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]); const currentYear = resp?.current_year; - const saveMut = useMutation({ - mutationFn: () => - api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { - price_rials: Number(price) || 0, - }), + // سال‌های قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سال‌های ثبت‌شده. + const yearOptions = useMemo(() => { + if (!currentYear) return []; + const used = new Set(tariffs.map((t) => t.year)); + const opts: { value: number; label: string }[] = []; + for (let y = currentYear + 2; y >= currentYear - 5; y--) { + if (used.has(y)) continue; + opts.push({ value: y, label: y === currentYear ? `${formatNumber(y)} (سال جاری)` : formatNumber(y) }); + } + return opts; + }, [currentYear, tariffs]); + + const addMut = useMutation({ + mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: price }), onSuccess: () => { toast.success('تعرفه ذخیره شد'); setYear(''); - setPrice(''); + setPrice(0); qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] }); + qc.invalidateQueries({ queryKey: ['service-items'] }); }, onError: (e: Error) => toast.error(e.message), }); + const editMut = useMutation({ + mutationFn: (vars: { year: number; price: number }) => + api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${vars.year}`, { price_rials: vars.price }), + onSuccess: () => { + toast.success('تعرفه ویرایش شد'); + setEditYear(null); + qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] }); + qc.invalidateQueries({ queryKey: ['service-items'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const startEdit = (t: TariffRow) => { setEditYear(t.year); setEditPrice(t.price_rials); }; + return ( - -
+ +
{resp && ( -
- قیمت پیش‌فرض خدمت: {formatRial(resp.default_price_rials)}. اگر تعرفه‌ی سالی ثبت نشود، همین قیمت اعمال می‌شود. +
+ قیمت پایه‌ی سرویس همان تعرفه‌ی سال جاری ({currentYear ? formatNumber(currentYear) : '—'}) است و همه‌جا از همین استفاده می‌شود. تعرفه‌ی سال‌های دیگر فقط برای صورتحساب همان سال به‌کار می‌رود.
)} -
-
- - setYear(e.target.value)} /> + {/* افزودن تعرفه‌ی جدید */} +
+
افزودن تعرفه‌ی سال
+
+
+ + setYear(v != null && v !== '' ? Number(v) : '')} + placeholder="انتخاب سال" + noOptionsMessage="سالی باقی نمانده" + height={40} + /> +
+
+ + +
+
-
- - setPrice(e.target.value)} /> -
-
+ {/* فهرست تعرفه‌ها */} {isLoading ? (
در حال بارگذاری...
) : tariffs.length === 0 ? ( -
هنوز تعرفه‌ی سالانه‌ای ثبت نشده است.
+
هنوز تعرفه‌ای ثبت نشده است.
) : (
- {tariffs.map((t) => ( -
- - سال {formatNumber(t.year)} - {t.year === currentYear && جاری} - - {formatRial(t.price_rials)} -
- ))} + {tariffs.map((t) => { + const isCurrent = t.year === currentYear; + const isEditing = editYear === t.year; + return ( +
+ + سال {formatNumber(t.year)} + {isCurrent && جاری} + + + {isEditing ? ( + <> +
+ +
+ + + + ) : ( + <> + {formatRial(t.price_rials)} + + + )} +
+ ); + })}
)}
diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index 5de133ab..c724acf8 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -128,6 +128,8 @@ **Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`) +> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` به‌صورت خودکار ثبت می‌شود. قیمت سرویس = تعرفه‌ی سال جاری است و همه‌جا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده می‌شود. + --- ## PATCH /api/v1/service-item/{uuid} @@ -204,6 +206,8 @@ **Response 200:** `{ success, data: { …tariff } }` +> اگر `year` برابر **سال جاری** باشد، `ServiceItem.price_rials` هم با همین مقدار همگام می‌شود (قیمت واحد). تعرفه‌ی سال‌های دیگر فقط برای محاسبه‌ی صورتحساب همان سال (`TariffService::resolvePrice`) به‌کار می‌رود و قیمت پایه‌ی سرویس را تغییر نمی‌دهد. هم‌چنین `PATCH /service-item/{uuid}` با تغییر `price_rials`، تعرفه‌ی سال جاری را upsert می‌کند. + **Errors:** | Code | HTTP | توضیح | |------|------|-------| diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 286b9c1d..29205382 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -170,6 +170,9 @@ class ClinicServiceController extends BaseController $this->itemRepo->save($item); + // قیمت سرویس همان تعرفه‌ی سال جاری است؛ هنگام ساخت، تعرفه‌ی سال جاری ثبت می‌شود. + $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); + return $this->success($item->toArray(), 201); } @@ -185,8 +188,9 @@ class ClinicServiceController extends BaseController $data = json_decode($request->getContent(), true) ?? []; + $priceChanged = false; if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); } - if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); } + if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; } if (isset($data['active'])) { $item->setActive((bool) $data['active']); } if (array_key_exists('staff_uuid', $data)) { $staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null; @@ -201,6 +205,11 @@ class ClinicServiceController extends BaseController $this->itemRepo->save($item); + // اگر قیمت پایه تغییر کرد، تعرفه‌ی سال جاری هم همگام می‌شود (قیمت واحد). + if ($priceChanged) { + $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); + } + return $this->success($item->toArray()); } @@ -265,6 +274,12 @@ class ClinicServiceController extends BaseController $tariff = $this->tariffService->upsert($item->getId(), $year, $price); + // تعرفه‌ی سال جاری = قیمت پایه‌ی سرویس (قیمت واحد در همه‌جا). + if ($year === $this->tariffService->currentJalaliYear()) { + $item->setPriceRials($price); + $this->itemRepo->save($item); + } + return $this->success(['data' => $tariff->toArray()]); }