feat(tariffs): automate current year tariff registration and sync service price
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { formatRial, formatNumber } from '../lib/utils';
|
import { formatRial, formatNumber } from '../lib/utils';
|
||||||
import Modal from './ui/Modal';
|
import Modal from './ui/Modal';
|
||||||
|
import PriceInput from './ui/PriceInput';
|
||||||
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
import type { ServiceItem } from '../types';
|
import type { ServiceItem } from '../types';
|
||||||
|
|
||||||
interface TariffRow {
|
interface TariffRow {
|
||||||
@@ -21,8 +24,10 @@ interface TariffResponse {
|
|||||||
|
|
||||||
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [year, setYear] = useState('');
|
const [year, setYear] = useState<number | ''>('');
|
||||||
const [price, setPrice] = useState('');
|
const [price, setPrice] = useState(0);
|
||||||
|
const [editYear, setEditYear] = useState<number | null>(null);
|
||||||
|
const [editPrice, setEditPrice] = useState(0);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
|
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
|
||||||
queryKey: ['service-tariffs', item?.uuid],
|
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 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 currentYear = resp?.current_year;
|
||||||
|
|
||||||
const saveMut = useMutation({
|
// سالهای قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سالهای ثبتشده.
|
||||||
mutationFn: () =>
|
const yearOptions = useMemo(() => {
|
||||||
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, {
|
if (!currentYear) return [];
|
||||||
price_rials: Number(price) || 0,
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success('تعرفه ذخیره شد');
|
toast.success('تعرفه ذخیره شد');
|
||||||
setYear('');
|
setYear('');
|
||||||
setPrice('');
|
setPrice(0);
|
||||||
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
|
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['service-items'] });
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
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 (
|
return (
|
||||||
<Modal open={!!item} onClose={onClose} title={`تعرفههای سالانه — ${item?.name ?? ''}`}>
|
<Modal open={!!item} onClose={onClose} title={`تعرفههای سالانه — ${item?.name ?? ''}`} size="md">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{resp && (
|
{resp && (
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7 }}>
|
<div style={{
|
||||||
قیمت پیشفرض خدمت: <b>{formatRial(resp.default_price_rials)}</b>. اگر تعرفهی سالی ثبت نشود، همین قیمت اعمال میشود.
|
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
|
||||||
|
}}>
|
||||||
|
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatNumber(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', padding: 12, borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface)' }}>
|
{/* افزودن تعرفهی جدید */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||||
<label style={{ fontSize: 11.5, fontWeight: 600 }}>سال (شمسی)</label>
|
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
|
||||||
<input type="number" dir="ltr" className="input" style={{ width: 100 }} placeholder={currentYear ? String(currentYear) : '۱۴۰۴'} value={year} onChange={(e) => setYear(e.target.value)} />
|
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: 10, alignItems: 'end' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<label className="field-label">سال (شمسی)</label>
|
||||||
|
<SearchableSelect
|
||||||
|
options={yearOptions}
|
||||||
|
value={year === '' ? '' : year}
|
||||||
|
onChange={(v) => setYear(v != null && v !== '' ? Number(v) : '')}
|
||||||
|
placeholder="انتخاب سال"
|
||||||
|
noOptionsMessage="سالی باقی نمانده"
|
||||||
|
height={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<label className="field-label">تعرفه (ریال)</label>
|
||||||
|
<PriceInput className="input" style={{ height: 40 }} value={price} onChange={setPrice} placeholder="مبلغ" min={0} />
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" style={{ height: 40 }} disabled={year === '' || addMut.isPending} onClick={() => addMut.mutate()}>
|
||||||
|
{addMut.isPending ? '...' : 'افزودن'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<label style={{ fontSize: 11.5, fontWeight: 600 }}>تعرفه (ریال)</label>
|
|
||||||
<input type="number" min={0} dir="ltr" className="input" style={{ width: 150 }} value={price} onChange={(e) => setPrice(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<button className="btn primary sm" disabled={!year || saveMut.isPending} onClick={() => saveMut.mutate()}>
|
|
||||||
{saveMut.isPending ? '...' : 'ذخیره'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* فهرست تعرفهها */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
||||||
) : tariffs.length === 0 ? (
|
) : tariffs.length === 0 ? (
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>هنوز تعرفهی سالانهای ثبت نشده است.</div>
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{tariffs.map((t) => (
|
{tariffs.map((t) => {
|
||||||
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
const isCurrent = t.year === currentYear;
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
const isEditing = editYear === t.year;
|
||||||
سال {formatNumber(t.year)}
|
return (
|
||||||
{t.year === currentYear && <span className="badge green" style={{ fontSize: 10, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
|
<div key={t.uuid} style={{
|
||||||
</span>
|
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
|
||||||
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>{formatRial(t.price_rials)}</span>
|
borderRadius: 9, border: '1px solid var(--border)',
|
||||||
</div>
|
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
|
||||||
))}
|
}}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
|
||||||
|
سال {formatNumber(t.year)}
|
||||||
|
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<PriceInput className="input" style={{ height: 36 }} value={editPrice} onChange={setEditPrice} min={0} />
|
||||||
|
</div>
|
||||||
|
<button className="btn primary sm" disabled={editMut.isPending} onClick={() => editMut.mutate({ year: t.year, price: editPrice })} title="ذخیره">
|
||||||
|
<CheckIcon style={{ width: 14 }} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm" onClick={() => setEditYear(null)} title="انصراف">
|
||||||
|
<XMarkIcon style={{ width: 14 }} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ flex: 1, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }} dir="ltr">{formatRial(t.price_rials)}</span>
|
||||||
|
<button className="btn sm ghost" onClick={() => startEdit(t)} title="ویرایش">
|
||||||
|
<PencilIcon style={{ width: 14 }} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,6 +128,8 @@
|
|||||||
|
|
||||||
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
|
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
|
||||||
|
|
||||||
|
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` بهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PATCH /api/v1/service-item/{uuid}
|
## PATCH /api/v1/service-item/{uuid}
|
||||||
@@ -204,6 +206,8 @@
|
|||||||
|
|
||||||
**Response 200:** `{ success, data: { …tariff } }`
|
**Response 200:** `{ success, data: { …tariff } }`
|
||||||
|
|
||||||
|
> اگر `year` برابر **سال جاری** باشد، `ServiceItem.price_rials` هم با همین مقدار همگام میشود (قیمت واحد). تعرفهی سالهای دیگر فقط برای محاسبهی صورتحساب همان سال (`TariffService::resolvePrice`) بهکار میرود و قیمت پایهی سرویس را تغییر نمیدهد. همچنین `PATCH /service-item/{uuid}` با تغییر `price_rials`، تعرفهی سال جاری را upsert میکند.
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
| Code | HTTP | توضیح |
|
| Code | HTTP | توضیح |
|
||||||
|------|------|-------|
|
|------|------|-------|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ class ClinicServiceController extends BaseController
|
|||||||
|
|
||||||
$this->itemRepo->save($item);
|
$this->itemRepo->save($item);
|
||||||
|
|
||||||
|
// قیمت سرویس همان تعرفهی سال جاری است؛ هنگام ساخت، تعرفهی سال جاری ثبت میشود.
|
||||||
|
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||||
|
|
||||||
return $this->success($item->toArray(), 201);
|
return $this->success($item->toArray(), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +188,9 @@ class ClinicServiceController extends BaseController
|
|||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
|
$priceChanged = false;
|
||||||
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
|
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 (isset($data['active'])) { $item->setActive((bool) $data['active']); }
|
||||||
if (array_key_exists('staff_uuid', $data)) {
|
if (array_key_exists('staff_uuid', $data)) {
|
||||||
$staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null;
|
$staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null;
|
||||||
@@ -201,6 +205,11 @@ class ClinicServiceController extends BaseController
|
|||||||
|
|
||||||
$this->itemRepo->save($item);
|
$this->itemRepo->save($item);
|
||||||
|
|
||||||
|
// اگر قیمت پایه تغییر کرد، تعرفهی سال جاری هم همگام میشود (قیمت واحد).
|
||||||
|
if ($priceChanged) {
|
||||||
|
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success($item->toArray());
|
return $this->success($item->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +274,12 @@ class ClinicServiceController extends BaseController
|
|||||||
|
|
||||||
$tariff = $this->tariffService->upsert($item->getId(), $year, $price);
|
$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()]);
|
return $this->success(['data' => $tariff->toArray()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user