Files
clinicpro/assets/admin/hooks/useInventory.ts
T
hamedandClaude Opus 4.8 519bc8d7f6 feat: port inventory (انبارداری) page from tauri to admin dashboard
Add a per-tenant (doctor/clinic) Inventory domain and admin page, ported
from clinic-pro-tauri /inventory (which was static/mock) into a real feature.

Backend (src/Inventory/):
- Entities InventoryItem, InventoryPackage, InventoryPackageItem, scoped via
  entity_type/entity_id like TenantTag. Item status is derived, package total
  and availability derived at read time.
- InventoryService (stats, package assembly, availability), thin
  InventoryController with CRUD for items and packages + categories endpoint.
- Migration + docs/api/inventory.md + functional tests (10 tests, 42 assertions).

Frontend (assets/admin/):
- InventoryPage with two tabs (کالاهای مصرفی / پکیج), stat cards, items table
  (desktop + mobile cards), packages accordion, add/edit item and package
  modals, search + category filter — pixel-matched to the tauri source.
- useInventory hook (TanStack Query), route + sidebar link for doctor/clinic.
- Vitest coverage (real data, empty state, modal, packages tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:48:29 +03:30

134 lines
4.8 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
// ── Types ────────────────────────────────────────────────────────────────────
export type InventoryStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
export interface InventoryItem {
uuid: string;
name: string;
consumable: string | null;
unit: string;
price: number; // Rial
stock: number;
alertThreshold: number;
status: InventoryStatus;
}
export interface InventoryStats {
total: number;
low: number;
inStock: number;
outOfStock: number;
}
export interface PackageLine {
itemUuid: string;
name: string;
unit: string;
price: number; // Rial
amount: number;
}
export interface InventoryPackage {
uuid: string;
title: string;
items: PackageLine[];
total: number; // Rial
available: boolean;
}
export interface ItemPayload {
name: string;
consumable?: string | null;
unit?: string;
price?: number;
stock?: number;
alertThreshold?: number;
}
export interface PackagePayload {
title: string;
items: { itemUuid: string; amount: number }[];
}
const EMPTY_STATS: InventoryStats = { total: 0, low: 0, inStock: 0, outOfStock: 0 };
const EMPTY_ITEMS: InventoryItem[] = [];
const EMPTY_PACKAGES: InventoryPackage[] = [];
const EMPTY_CATS: string[] = [];
// ── Hook ─────────────────────────────────────────────────────────────────────
/** Per-tenant inventory: items (+stats), packages and category filter options. */
export function useInventory() {
const qc = useQueryClient();
const itemsQuery = useQuery<ApiResponse<{ items: InventoryItem[]; stats: InventoryStats }>>({
queryKey: ['inventory-items'],
queryFn: () => api.get('/api/v1/inventory-items'),
});
const packagesQuery = useQuery<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'),
});
const categoriesQuery = useQuery<ApiResponse<string[]>>({
queryKey: ['inventory-categories'],
queryFn: () => api.get('/api/v1/inventory-categories'),
});
const invalidateItems = () => {
qc.invalidateQueries({ queryKey: ['inventory-items'] });
qc.invalidateQueries({ queryKey: ['inventory-categories'] });
qc.invalidateQueries({ queryKey: ['inventory-packages'] });
};
const invalidatePackages = () => qc.invalidateQueries({ queryKey: ['inventory-packages'] });
const createItem = useMutation({
mutationFn: (d: ItemPayload) => api.post('/api/v1/inventory-item', d),
onSuccess: () => { invalidateItems(); toast.success('کالا اضافه شد'); },
onError: (e: any) => toast.error(e.message),
});
const updateItem = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: ItemPayload }) => api.patch(`/api/v1/inventory-item/${uuid}`, d),
onSuccess: () => { invalidateItems(); toast.success('کالا ویرایش شد'); },
onError: (e: any) => toast.error(e.message),
});
const deleteItem = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-item/${uuid}`),
onSuccess: () => { invalidateItems(); toast.success('کالا حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
const createPackage = useMutation({
mutationFn: (d: PackagePayload) => api.post('/api/v1/inventory-package', d),
onSuccess: () => { invalidatePackages(); toast.success('پکیج اضافه شد'); },
onError: (e: any) => toast.error(e.message),
});
const updatePackage = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: PackagePayload }) => api.patch(`/api/v1/inventory-package/${uuid}`, d),
onSuccess: () => { invalidatePackages(); toast.success('پکیج ویرایش شد'); },
onError: (e: any) => toast.error(e.message),
});
const deletePackage = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-package/${uuid}`),
onSuccess: () => { invalidatePackages(); toast.success('پکیج حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
return {
items: itemsQuery.data?.data?.items ?? EMPTY_ITEMS,
stats: itemsQuery.data?.data?.stats ?? EMPTY_STATS,
packages: packagesQuery.data?.data ?? EMPTY_PACKAGES,
categories: categoriesQuery.data?.data ?? EMPTY_CATS,
itemsLoading: itemsQuery.isLoading,
packagesLoading: packagesQuery.isLoading,
createItem, updateItem, deleteItem,
createPackage, updatePackage, deletePackage,
};
}