720 lines
34 KiB
TypeScript
720 lines
34 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { Search, AlertCircle, Package, Layers, Plus, X, ChevronDown } from 'lucide-react';
|
|
import { createColumnHelper } from '@tanstack/react-table';
|
|
import { Table } from '../components/Table';
|
|
import Pagination from '../components/Pagination';
|
|
import { CustomSelect } from '../components/CustomSelect';
|
|
import { StockDropdown } from '../components/StockDropdown';
|
|
import { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder, downloadInvoice, fetchSyncedPatients } from '../services/api';
|
|
|
|
interface PharmacyItem {
|
|
id: string;
|
|
medicine: string;
|
|
subtitle: string;
|
|
badge: string;
|
|
stock: number;
|
|
rawPrice: number;
|
|
price: string;
|
|
manufacture: string;
|
|
type: string;
|
|
}
|
|
|
|
const columnHelper = createColumnHelper<PharmacyItem>();
|
|
|
|
const columns = [
|
|
columnHelper.accessor('medicine', {
|
|
header: 'Medicine',
|
|
size: 350,
|
|
cell: info => {
|
|
const item = info.row.original;
|
|
return (
|
|
<div className="py-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-slate-800 text-[14px]">{item.medicine}</span>
|
|
{item.badge && (
|
|
<span className="bg-blue-600 text-white text-[10px] px-2 py-0.5 rounded font-semibold uppercase tracking-wide">
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[13px] text-slate-400 block mt-1">{item.subtitle}</span>
|
|
</div>
|
|
);
|
|
}
|
|
}),
|
|
columnHelper.accessor('stock', {
|
|
header: 'Stock',
|
|
size: 100,
|
|
cell: info => {
|
|
const stock = info.getValue();
|
|
const isLow = stock < 50;
|
|
return (
|
|
<span className={`font-medium text-[14px] ${isLow ? 'text-red-500' : 'text-slate-800'}`}>
|
|
{stock}
|
|
</span>
|
|
);
|
|
}
|
|
}),
|
|
columnHelper.accessor('price', {
|
|
header: 'Purchase Price / MRP',
|
|
size: 150,
|
|
cell: info => <span className="text-slate-800 font-medium text-[14px]">{info.getValue()}</span>
|
|
}),
|
|
columnHelper.accessor('manufacture', {
|
|
header: 'Manufacturer',
|
|
size: 180,
|
|
cell: info => <span className="text-slate-600 text-[14px]">{info.getValue()}</span>
|
|
}),
|
|
columnHelper.accessor('type', {
|
|
header: 'Type',
|
|
size: 100,
|
|
cell: info => <span className="text-slate-600 text-[14px] capitalize">{info.getValue()}</span>
|
|
}),
|
|
];
|
|
|
|
const Pharmacy = () => {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(25);
|
|
const [inventory, setInventory] = useState<PharmacyItem[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// Add Medicine Modal State
|
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [addFormData, setAddFormData] = useState({
|
|
brand_name: '',
|
|
generic_name: '',
|
|
manufacturer: '',
|
|
form: 'tablet',
|
|
strength: '',
|
|
mrp: '',
|
|
pack_size: '1',
|
|
schedule: 'H'
|
|
});
|
|
|
|
// Dispense Modal State
|
|
const [isDispenseModalOpen, setIsDispenseModalOpen] = useState(false);
|
|
const [dispenseStep, setDispenseStep] = useState(1); // 1: items, 2: precheck, 3: success
|
|
const [orderDraftId, setOrderDraftId] = useState<string | null>(null);
|
|
const [patientName, setPatientName] = useState('');
|
|
const [prescriberName, setPrescriberName] = useState('');
|
|
const [selectedItems, setSelectedItems] = useState<{product_id: string, name: string, qty: number, price: number}[]>([]);
|
|
const [precheckResult, setPrecheckResult] = useState<any>(null);
|
|
const [isProcessingDispense, setIsProcessingDispense] = useState(false);
|
|
|
|
const [dispensingId, setDispensingId] = useState<string | null>(null);
|
|
|
|
const resetDispenseModal = () => {
|
|
setIsDispenseModalOpen(false);
|
|
setDispenseStep(1);
|
|
setOrderDraftId(null);
|
|
setDispensingId(null);
|
|
setPatientName('');
|
|
setSelectedItems([]);
|
|
setPrecheckResult(null);
|
|
};
|
|
|
|
const handleCreateDraft = async () => {
|
|
if (!patientName || selectedItems.length === 0) return alert("Enter patient name and select items.");
|
|
try {
|
|
setIsProcessingDispense(true);
|
|
const draft = await createOrderDraft({ subject_ref: patientName });
|
|
setOrderDraftId(draft.order_draft_id);
|
|
|
|
const itemsPayload = selectedItems.map(i => ({ product_id: i.product_id, qty: i.qty }));
|
|
await updateOrderItems(draft.order_draft_id, itemsPayload);
|
|
|
|
const precheck = await precheckOrder(draft.order_draft_id, {
|
|
clinical_context: { patient_id: patientName, allergies: [] },
|
|
items: itemsPayload
|
|
});
|
|
setPrecheckResult(precheck);
|
|
setDispenseStep(2);
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Failed to prepare order.");
|
|
} finally {
|
|
setIsProcessingDispense(false);
|
|
}
|
|
};
|
|
|
|
const handleExecuteDispense = async () => {
|
|
if (!orderDraftId) return;
|
|
try {
|
|
setIsProcessingDispense(true);
|
|
const res = await dispenseOrder(orderDraftId, {
|
|
subject_ref: patientName,
|
|
prescriber_name: prescriberName,
|
|
payment_method: 'cash'
|
|
});
|
|
setDispensingId(res.dispensing_id);
|
|
setDispenseStep(3);
|
|
loadData(); // refresh inventory
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
alert("Dispense failed: " + (e.response?.data?.message || "Check stock."));
|
|
} finally {
|
|
setIsProcessingDispense(false);
|
|
}
|
|
};
|
|
|
|
const handleAddSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
setIsAdding(true);
|
|
await addMedicine(addFormData);
|
|
setIsAddModalOpen(false);
|
|
setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', pack_size: '1', schedule: 'H' });
|
|
await loadData();
|
|
} catch (err) {
|
|
console.error("Failed to add medicine", err);
|
|
alert("Failed to add medicine. Please try again.");
|
|
} finally {
|
|
setIsAdding(false);
|
|
}
|
|
};
|
|
|
|
const mapAndSetData = (drugs: any[]) => {
|
|
const mappedData: PharmacyItem[] = drugs.map((drug: any) => {
|
|
const mrp = parseFloat(drug.mrp) || 0;
|
|
const packSize = parseInt(drug.pack_size, 10) || 1;
|
|
const unitPrice = mrp / packSize;
|
|
|
|
return {
|
|
id: drug.product_id,
|
|
medicine: `${drug.brand_name} ${drug.strength || ''}`.trim(),
|
|
subtitle: drug.generic_name || '',
|
|
badge: drug.schedule || '',
|
|
stock: parseInt(drug.total_stock) || 0,
|
|
rawPrice: unitPrice,
|
|
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(unitPrice),
|
|
manufacture: drug.manufacturer || 'Unknown',
|
|
type: drug.form || 'Unknown'
|
|
};
|
|
});
|
|
setInventory(mappedData);
|
|
};
|
|
|
|
const [hospitalPatients, setHospitalPatients] = useState<any[]>([]);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
if (!localStorage.getItem('auth_token')) {
|
|
await authenticate();
|
|
}
|
|
|
|
const [data, patients] = await Promise.all([
|
|
getDrugs(),
|
|
fetchSyncedPatients().catch(() => []) // fail gracefully if hospital api is down
|
|
]);
|
|
|
|
if (data && data.drugs) {
|
|
mapAndSetData(data.drugs);
|
|
}
|
|
if (patients) {
|
|
setHospitalPatients(patients);
|
|
}
|
|
} catch (error: any) {
|
|
if (error.response && error.response.status === 401) {
|
|
try {
|
|
await authenticate();
|
|
const retryData = await getDrugs();
|
|
if (retryData && retryData.drugs) {
|
|
mapAndSetData(retryData.drugs);
|
|
}
|
|
const retryPatients = await fetchSyncedPatients().catch(() => []);
|
|
setHospitalPatients(retryPatients);
|
|
} catch (retryError) {
|
|
console.error("Failed to fetch data after retry:", retryError);
|
|
setError("Authentication failed. Please try logging in again.");
|
|
}
|
|
} else {
|
|
console.error("Failed to fetch data:", error);
|
|
setError("Failed to connect to the server. Please try again later.");
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('action') === 'dispense') {
|
|
setIsDispenseModalOpen(true);
|
|
window.history.replaceState({}, '', '/pharmacy');
|
|
}
|
|
}, []);
|
|
|
|
// Listen for external search triggers (e.g., Voice Assistant or Chat Widget)
|
|
useEffect(() => {
|
|
const handleSearchEvent = (e: any) => {
|
|
if (typeof e.detail === 'string') {
|
|
setSearchQuery(e.detail);
|
|
}
|
|
};
|
|
window.addEventListener('setPharmacySearch', handleSearchEvent);
|
|
window.addEventListener('setSearchQuery', handleSearchEvent);
|
|
|
|
return () => {
|
|
window.removeEventListener('setPharmacySearch', handleSearchEvent);
|
|
window.removeEventListener('setSearchQuery', handleSearchEvent);
|
|
};
|
|
}, []);
|
|
|
|
// Automatically sync search query when navigating with ?search=query
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(location.search);
|
|
const searchParam = params.get('search');
|
|
if (searchParam !== null) {
|
|
setSearchQuery(searchParam);
|
|
}
|
|
}, [location.search]);
|
|
|
|
// Filter inventory based on search query with smart matching
|
|
const filteredInventory = useMemo(() => {
|
|
if (!searchQuery.trim()) return inventory;
|
|
const cleanSearch = searchQuery.trim().toLowerCase();
|
|
const compactSearch = cleanSearch.replace(/[\s\-_]+/g, '');
|
|
|
|
return inventory.filter(item => {
|
|
const med = item.medicine.toLowerCase();
|
|
const sub = item.subtitle.toLowerCase();
|
|
const mfr = item.manufacture.toLowerCase();
|
|
const compactMed = med.replace(/[\s\-_]+/g, '');
|
|
|
|
return med.includes(cleanSearch) ||
|
|
sub.includes(cleanSearch) ||
|
|
mfr.includes(cleanSearch) ||
|
|
compactMed.includes(compactSearch);
|
|
});
|
|
}, [inventory, searchQuery]);
|
|
|
|
// Reset pagination when search changes
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchQuery]);
|
|
|
|
const totalItems = filteredInventory.length;
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
|
|
|
const currentData = filteredInventory.slice(
|
|
(currentPage - 1) * itemsPerPage,
|
|
currentPage * itemsPerPage
|
|
);
|
|
|
|
// Business Metrics
|
|
const lowStockCount = inventory.filter(item => item.stock < 50).length;
|
|
const uniqueCategories = new Set(inventory.map(item => item.type)).size;
|
|
|
|
return (
|
|
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
|
|
<div className="flex flex-col mb-8 gap-2">
|
|
<div className="flex items-center gap-4">
|
|
<div className="bg-blue-100 p-3 rounded-xl flex items-center justify-center">
|
|
<Package className="text-blue-600" size={26} />
|
|
</div>
|
|
<h1 className="text-3xl font-bold text-slate-900 m-0 leading-none">Pharmacy Overview</h1>
|
|
</div>
|
|
<p className="text-slate-500 mt-2">Manage your pharmacy inventory, add medicines, and handle manual dispensing.</p>
|
|
</div>
|
|
|
|
{/* Metrics Row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0" style={{ marginBottom: '32px' }}>
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5" style={{ padding: "24px" }}>
|
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
|
<Package className="text-blue-600" size={28} />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Total Medicines</p>
|
|
<h3 className="text-3xl font-extrabold text-slate-900">{inventory.length}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5 relative overflow-hidden">
|
|
{lowStockCount > 0 && (
|
|
<div className="absolute top-0 right-0 w-2 h-full bg-red-500"></div>
|
|
)}
|
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-red-50 to-red-100 border border-red-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
|
<AlertCircle className="text-red-600" size={28} />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Low Stock Alerts</p>
|
|
<h3 className={`text-3xl font-extrabold ${lowStockCount > 0 ? 'text-red-600' : 'text-slate-900'}`}>{lowStockCount}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5" style={{ padding: "24px" }}>
|
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-indigo-50 to-indigo-100 border border-indigo-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
|
<Layers className="text-indigo-600" size={28} />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Unique Categories</p>
|
|
<h3 className="text-3xl font-extrabold text-slate-900">{uniqueCategories}</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Bar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
|
<div className="relative w-full flex-1 max-w-none group">
|
|
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}>
|
|
<Search size={20} strokeWidth={2} />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search medicines, OTC, or prescriptions..."
|
|
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
|
|
style={{ height: '46px', paddingLeft: '44px', paddingRight: searchQuery ? '40px' : '16px', fontSize: '15px' }}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
navigate('/pharmacy', { replace: true });
|
|
}}
|
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600 transition-colors"
|
|
title="Clear search"
|
|
type="button"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
<div className="w-full flex flex-col bg-white border border-slate-200 rounded-2xl shadow-sm relative" style={{ marginTop: '16px' }}>
|
|
<div className="overflow-x-auto">
|
|
{error ? (
|
|
<div className="p-8 text-center text-red-500">
|
|
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>{error}</p>
|
|
<button
|
|
onClick={loadData}
|
|
className="mt-4 px-4 py-2 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
) : isLoading ? (
|
|
<div className="p-8 flex flex-col items-center justify-center min-h-[400px]">
|
|
<div className="w-10 h-10 border-4 border-slate-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
|
|
<p className="text-slate-500">Loading pharmacy records...</p>
|
|
</div>
|
|
) : inventory.length === 0 ? (
|
|
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
|
|
<Package className="w-16 h-16 mx-auto mb-6 opacity-20" />
|
|
<h3 className="text-xl font-medium text-slate-700 mb-2">No Medicines Found</h3>
|
|
<p className="max-w-md mx-auto">Your pharmacy inventory is currently empty or no items match your search.</p>
|
|
<button
|
|
onClick={loadData}
|
|
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
|
>
|
|
Refresh Inventory
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<Table
|
|
data={currentData}
|
|
columns={columns}
|
|
getRowId={(item) => item.id}
|
|
/>
|
|
)}
|
|
</div>
|
|
{totalItems > 0 && !isLoading && !error && (
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
itemsPerPage={itemsPerPage}
|
|
itemsPerPageOptions={[10, 25, 50, 100]}
|
|
onPageChange={(page) => setCurrentPage(page)}
|
|
onItemsPerPageChange={(limit) => {
|
|
setItemsPerPage(limit);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Medicine Modal */}
|
|
{isAddModalOpen && (
|
|
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50" style={{ padding: '24px' }}>
|
|
<h2 className="text-xl font-bold text-slate-800">Add New Medicine to Catalogue</h2>
|
|
<button
|
|
onClick={() => setIsAddModalOpen(false)}
|
|
className="text-slate-400 hover:text-slate-600 transition-colors p-2 hover:bg-slate-100 rounded-full"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleAddSubmit} style={{ padding: '24px' }}>
|
|
<div className="grid grid-cols-2" style={{ gap: '24px', marginBottom: '24px' }}>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Brand Name</label>
|
|
<input required type="text" value={addFormData.brand_name} onChange={e => setAddFormData({...addFormData, brand_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="e.g. Calpol 500" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Generic Name</label>
|
|
<input required type="text" value={addFormData.generic_name} onChange={e => setAddFormData({...addFormData, generic_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="e.g. Paracetamol" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Manufacturer</label>
|
|
<input type="text" value={addFormData.manufacturer} onChange={e => setAddFormData({...addFormData, manufacturer: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="e.g. GSK" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Form</label>
|
|
<CustomSelect value={addFormData.form} onChange={e => setAddFormData({...addFormData, form: e.target.value})}>
|
|
<option value="tablet">Tablet</option>
|
|
<option value="capsule">Capsule</option>
|
|
<option value="syrup">Syrup</option>
|
|
<option value="injection">Injection</option>
|
|
<option value="ointment">Ointment</option>
|
|
</CustomSelect>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Strength</label>
|
|
<input type="text" value={addFormData.strength} onChange={e => setAddFormData({...addFormData, strength: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="e.g. 500mg" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>MRP (₹)</label>
|
|
<input required type="number" step="0.01" value={addFormData.mrp} onChange={e => setAddFormData({...addFormData, mrp: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="0.00" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Pack Size</label>
|
|
<input type="number" value={addFormData.pack_size} onChange={e => setAddFormData({...addFormData, pack_size: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="1" style={{ padding: '12px 16px' }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end border-t border-slate-100" style={{ paddingTop: '16px', gap: '12px' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAddModalOpen(false)}
|
|
className="text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition-colors"
|
|
style={{ padding: '10px 20px' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isAdding}
|
|
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
|
|
style={{ padding: '10px 20px', gap: '8px' }}
|
|
>
|
|
{isAdding ? 'Adding...' : 'Add Medicine'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Dispense Modal */}
|
|
{isDispenseModalOpen && (
|
|
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50" style={{ padding: '24px' }}>
|
|
<h2 className="text-xl font-bold text-slate-800">Manual Dispense</h2>
|
|
<button
|
|
onClick={resetDispenseModal}
|
|
className="text-slate-400 hover:text-slate-600 transition-colors p-2 hover:bg-slate-100 rounded-full"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ padding: '24px' }}>
|
|
{dispenseStep === 1 && (
|
|
<div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2" style={{ gap: '16px', marginBottom: '16px' }}>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name (from Hospital)</label>
|
|
<input
|
|
type="text"
|
|
value={patientName}
|
|
onChange={e => setPatientName(e.target.value)}
|
|
list="patients-list"
|
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500"
|
|
style={{ padding: '12px 16px' }}
|
|
placeholder="e.g. John Doe"
|
|
/>
|
|
<datalist id="patients-list">
|
|
{hospitalPatients.map((p, idx) => (
|
|
<option key={idx} value={p.full_name}>{p.patient_code}</option>
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Prescriber Name</label>
|
|
<input
|
|
type="text"
|
|
value={prescriberName}
|
|
onChange={e => setPrescriberName(e.target.value)}
|
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500"
|
|
style={{ padding: '12px 16px' }}
|
|
placeholder="e.g. Dr. Smith"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Select Medicines (from inventory)</label>
|
|
<StockDropdown
|
|
mode="product"
|
|
placeholder="Search or select a medicine to add..."
|
|
options={inventory.map(i => ({
|
|
id: i.id,
|
|
product_id: i.id,
|
|
brand_name: i.medicine,
|
|
generic_name: i.subtitle,
|
|
qty_on_hand: i.stock,
|
|
mrp: i.rawPrice,
|
|
manufacturer: i.manufacture
|
|
}))}
|
|
onChange={(val, item) => {
|
|
if (item && val && !selectedItems.find(i => i.product_id === val)) {
|
|
setSelectedItems([
|
|
...selectedItems,
|
|
{
|
|
product_id: item.product_id || item.id || val,
|
|
name: item.brand_name || '',
|
|
qty: 1,
|
|
price: Number(item.mrp || 0)
|
|
}
|
|
]);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{selectedItems.length > 0 && (
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Selected Items</label>
|
|
<div className="space-y-2">
|
|
{selectedItems.map((item, idx) => (
|
|
<div key={idx} className="flex items-center bg-white border border-slate-200 rounded-lg" style={{ gap: '16px', padding: '12px' }}>
|
|
<span className="flex-1 font-medium">{item.name}</span>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={item.qty}
|
|
onChange={(e) => {
|
|
const newItems = [...selectedItems];
|
|
newItems[idx].qty = parseInt(e.target.value) || 1;
|
|
setSelectedItems(newItems);
|
|
}}
|
|
className="w-20 border border-slate-200 rounded-md"
|
|
style={{ padding: '4px 8px' }}
|
|
/>
|
|
<button className="text-red-500" onClick={() => setSelectedItems(selectedItems.filter((_, i) => i !== idx))}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end border-t border-slate-100" style={{ paddingTop: '16px' }}>
|
|
<button
|
|
onClick={handleCreateDraft}
|
|
disabled={isProcessingDispense}
|
|
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-70"
|
|
style={{ padding: '10px 24px' }}
|
|
>
|
|
{isProcessingDispense ? 'Preparing...' : 'Run Precheck'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dispenseStep === 2 && precheckResult && (
|
|
<div>
|
|
<h3 className="font-bold text-slate-800" style={{ marginBottom: '8px' }}>Safety Pre-Check Results</h3>
|
|
<div className="space-y-2 bg-slate-50 rounded-xl border border-slate-200" style={{ marginBottom: '16px', padding: '16px' }}>
|
|
{precheckResult.warnings?.map((w: any, idx: number) => (
|
|
<div key={idx} className="flex items-center gap-2 text-sm text-amber-700 font-medium">
|
|
<AlertCircle size={16} /> {w.code} - {w.batch_no}
|
|
</div>
|
|
))}
|
|
{(!precheckResult.warnings || precheckResult.warnings.length === 0) && (
|
|
<div className="flex items-center gap-2 text-sm text-emerald-600 font-medium">
|
|
All checks passed. No warnings.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center" style={{ marginBottom: '24px' }}>
|
|
<span className="font-bold text-slate-600">Total Bill Amount</span>
|
|
<span className="text-xl font-bold text-slate-900">₹{precheckResult.total_amount?.toFixed(2) || '0.00'}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-end border-t border-slate-100" style={{ paddingTop: '16px' }}>
|
|
<button
|
|
onClick={handleExecuteDispense}
|
|
disabled={isProcessingDispense}
|
|
className="bg-emerald-600 text-white font-semibold rounded-xl hover:bg-emerald-700 transition-colors shadow-sm disabled:opacity-70 flex items-center gap-2"
|
|
style={{ padding: '10px 24px' }}
|
|
>
|
|
<Package size={18} />
|
|
{isProcessingDispense ? 'Dispensing...' : 'Confirm Dispense'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dispenseStep === 3 && (
|
|
<div className="text-center" style={{ padding: '32px 0' }}>
|
|
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto" style={{ marginBottom: '16px' }}>
|
|
<Package className="text-emerald-600" size={32} />
|
|
</div>
|
|
<h3 className="font-bold text-emerald-800 text-xl" style={{ marginBottom: '8px' }}>Dispense Successful!</h3>
|
|
<p className="text-emerald-600" style={{ marginBottom: '24px' }}>Stock has been deducted and logged.</p>
|
|
<div className="flex justify-center gap-4">
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await downloadInvoice(dispensingId || orderDraftId!);
|
|
} catch (e) {
|
|
alert("Failed to download invoice");
|
|
console.error(e);
|
|
}
|
|
}}
|
|
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors flex items-center gap-2"
|
|
style={{ padding: '10px 24px' }}
|
|
>
|
|
Download Invoice
|
|
</button>
|
|
<button
|
|
onClick={resetDispenseModal}
|
|
className="bg-slate-800 text-white font-semibold rounded-xl hover:bg-slate-900 transition-colors"
|
|
style={{ padding: '10px 24px' }}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Pharmacy;
|