feat: add optional inventory package association to service items and implement audit logging

- Added `inventory_package_uuid` and `inventory_package_title` fields to the `ServiceItem` interface.
- Updated API documentation to reflect new fields in service item responses.
- Implemented methods in `ClinicServiceController` to handle inventory package associations.
- Created `ServiceItemAuditLog` entity and repository for tracking changes to service items.
- Added functionality to log changes to service items, including inventory package associations.
- Implemented tests for attaching/detaching inventory packages and auditing changes.
- Created database migrations for new fields and audit log table.
This commit is contained in:
hamed
2026-07-18 12:26:15 +03:30
parent 42d9ad26c5
commit c4a661b542
14 changed files with 885 additions and 24 deletions
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ServiceItem, ClinicStaff } from '../types';
import type { InventoryPackage } from '../hooks/useInventory';
import { rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import Modal from './ui/Modal';
@@ -20,10 +21,15 @@ const itemSchema = z.object({
staff_uuids: z.array(z.string()).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
bookable: z.boolean().optional(),
/** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */
inventory_package_uuid: z.string().optional(),
});
type ItemForm = z.infer<typeof itemSchema>;
const EMPTY_FORM: ItemForm = { name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, bookable: false };
const EMPTY_FORM: ItemForm = {
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
bookable: false, inventory_package_uuid: '',
};
interface Props {
/** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */
@@ -52,6 +58,13 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
});
const allStaff = staffData?.data ?? [];
const { data: packagesData } = useQuery<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'),
enabled: item !== null,
});
const packages = packagesData?.data ?? [];
const form = useForm<ItemForm>({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
useEffect(() => {
@@ -63,6 +76,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
duration_minutes: editing.duration_minutes ?? undefined,
bookable: editing.bookable ?? false,
inventory_package_uuid: editing.inventory_package_uuid ?? '',
}
: EMPTY_FORM);
}, [item]);
@@ -73,21 +87,24 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] });
};
// رشته‌ی خالی در payload یعنی «بدون پکیج»؛ بک‌اند null می‌خواهد.
const toPayload = (body: ItemForm) => ({
...body,
price_rials: tomanToRial(body.price_rials),
inventory_package_uuid: body.inventory_package_uuid || null,
});
const createItem = useMutation({
mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', {
...body,
...toPayload(body),
section_uuid: sectionUuid,
price_rials: tomanToRial(body.price_rials),
}),
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); },
onError: (e: Error) => toast.error(e.message),
});
const editItem = useMutation({
mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, {
...body,
price_rials: tomanToRial(body.price_rials),
}),
mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, toPayload(body)),
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); },
onError: (e: Error) => toast.error(e.message),
});
@@ -204,6 +221,19 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
</label>
</div>
<div>
<label className="field-label">پکیج کالای مصرفی</label>
<SearchableSelect
options={packages.map((p) => ({ value: p.uuid, label: p.title }))}
value={form.watch('inventory_package_uuid') || null}
onChange={(v) => form.setValue('inventory_package_uuid', v == null ? '' : String(v))}
placeholder="بدون پکیج (اختیاری)"
noOptionsMessage="پکیجی تعریف نشده است"
isClearable
height={42}
/>
</div>
</div>
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */}