refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { formatDate, formatRial } from '../lib/utils';
|
||||
import { useAppointmentInvoice } from '../hooks/usePriceLists';
|
||||
import { useAppointmentInvoice } from '../hooks/useAppointmentInvoice';
|
||||
|
||||
interface Props {
|
||||
appointmentUuid: string;
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
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, formatYear, rialToToman, tomanToRial } 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 {
|
||||
uuid: string;
|
||||
year: number;
|
||||
price_rials: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface TariffResponse {
|
||||
current_year: number;
|
||||
default_price_rials: number;
|
||||
data: TariffRow[];
|
||||
}
|
||||
|
||||
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [year, setYear] = useState<number | ''>('');
|
||||
const [price, setPrice] = useState(0);
|
||||
const [editYear, setEditYear] = useState<number | null>(null);
|
||||
const [editPrice, setEditPrice] = useState(0);
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
|
||||
queryKey: ['service-tariffs', item?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
|
||||
enabled: !!item,
|
||||
});
|
||||
|
||||
const resp = (data as any)?.data as TariffResponse | undefined;
|
||||
const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]);
|
||||
const currentYear = resp?.current_year;
|
||||
|
||||
// سالهای قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سالهای ثبتشده.
|
||||
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 ? `${formatYear(y)} (سال جاری)` : formatYear(y) });
|
||||
}
|
||||
return opts;
|
||||
}, [currentYear, tariffs]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: tomanToRial(price) }),
|
||||
onSuccess: () => {
|
||||
toast.success('تعرفه ذخیره شد');
|
||||
setYear('');
|
||||
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: tomanToRial(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(rialToToman(t.price_rials)); };
|
||||
|
||||
return (
|
||||
<Modal open={!!item} onClose={onClose} title={`تعرفههای سالانه — ${item?.name ?? ''}`} size="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{resp && (
|
||||
<div style={{
|
||||
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 ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* افزودن تعرفهی جدید */}
|
||||
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
|
||||
<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>
|
||||
|
||||
{/* فهرست تعرفهها */}
|
||||
{isLoading ? (
|
||||
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : tariffs.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{tariffs.map((t) => {
|
||||
const isCurrent = t.year === currentYear;
|
||||
const isEditing = editYear === t.year;
|
||||
return (
|
||||
<div key={t.uuid} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
|
||||
borderRadius: 9, border: '1px solid var(--border)',
|
||||
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
|
||||
سال {formatYear(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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'service-categories', label: 'دستهبندیها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'price-lists', label: 'لیستهای قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
Reference in New Issue
Block a user