first commit .
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { Activity, Package, Pill, Users } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getExpiringInventory, authenticate, getAuditLogs } from '../services/api';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [inventoryCount, setInventoryCount] = useState<number | string>('...');
|
||||
const [recentLogs, setRecentLogs] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (!localStorage.getItem('auth_token')) {
|
||||
await authenticate();
|
||||
}
|
||||
const data = await getExpiringInventory();
|
||||
if (data && data.batches) {
|
||||
setInventoryCount(data.batches.length);
|
||||
}
|
||||
|
||||
const logsData = await getAuditLogs();
|
||||
if (logsData && logsData.stock_movements) {
|
||||
setRecentLogs(logsData.stock_movements);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboard data", err);
|
||||
setInventoryCount('Error');
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total Patients', value: '1,234', icon: Users, color: 'text-blue-400' },
|
||||
{ label: 'Prescriptions', value: '856', icon: Activity, color: 'text-green-400' },
|
||||
{ label: 'Inventory Items', value: inventoryCount, icon: Package, color: 'text-purple-400' },
|
||||
{ label: 'Dispensed Today', value: '142', icon: Pill, color: 'text-pink-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<header className="mb-8">
|
||||
<h1>AI Pharmacy Dashboard</h1>
|
||||
<p className="text-gray-400 mt-2">Welcome to your intelligent pharmacy management system.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid-cols-3">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="glass-panel flex items-center gap-4">
|
||||
<div className={`p-3 rounded-full bg-white/10 ${stat.color}`}>
|
||||
<stat.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{stat.label}</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid-cols-3">
|
||||
<div className="glass-panel" style={{ gridColumn: 'span 2' }}>
|
||||
<h2>Recent Activity</h2>
|
||||
<div className="space-y-4 mt-4">
|
||||
{recentLogs.length > 0 ? recentLogs.map((log) => {
|
||||
const isReceipt = log.movement_type === 'receipt';
|
||||
const date = new Date(log.created_at);
|
||||
return (
|
||||
<div key={log.id} className="flex justify-between items-center p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div>
|
||||
<p className="font-medium">{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no})
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<p className="text-sm text-gray-400">No recent activity.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel">
|
||||
<h2>Quick Actions</h2>
|
||||
<div className="space-y-4 mt-4 flex flex-col">
|
||||
<button className="btn btn-primary w-full">New Prescription</button>
|
||||
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">Add Inventory</button>
|
||||
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">View Reports</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { History as HistoryIcon, Clock, Package, AlertTriangle, Search } from 'lucide-react';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { getDispensingHistory } from '../services/api';
|
||||
|
||||
interface DispensingHistoryRecord {
|
||||
dispensing_id: string;
|
||||
created_at: string;
|
||||
patient_name: string;
|
||||
prescription_id: string | null;
|
||||
items: any[];
|
||||
total_amount: number;
|
||||
payment_method: string;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<DispensingHistoryRecord>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('dispensing_id', {
|
||||
header: 'DISPENSING ID',
|
||||
size: 150,
|
||||
cell: info => (
|
||||
<span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 tracking-wide font-bold">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('created_at', {
|
||||
header: 'DATE',
|
||||
size: 180,
|
||||
cell: info => {
|
||||
const date = new Date(info.getValue());
|
||||
return <span className="text-slate-600 font-medium text-[13px]">{date.toLocaleString()}</span>;
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('patient_name', {
|
||||
header: 'PATIENT NAME',
|
||||
size: 200,
|
||||
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('prescription_id', {
|
||||
header: 'PRESCRIPTION REF',
|
||||
size: 180,
|
||||
cell: info => {
|
||||
const val = info.getValue();
|
||||
return val ? (
|
||||
<span className="text-blue-600 font-bold text-[13px] tracking-wide">{val}</span>
|
||||
) : (
|
||||
<span className="text-slate-400 text-[13px] italic font-medium">Direct Walk-in</span>
|
||||
);
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('items', {
|
||||
header: 'ITEMS DISPENSED',
|
||||
size: 350,
|
||||
cell: info => {
|
||||
const items = info.getValue() || [];
|
||||
return (
|
||||
<div className="space-y-1.5 py-1">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-[13px]">
|
||||
<Package size={14} className="text-slate-400 shrink-0" />
|
||||
<span className="font-bold text-slate-700 truncate">{item.brand_name}</span>
|
||||
<span className="text-slate-500 font-medium shrink-0">x {item.qty}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 border border-slate-200 px-1 rounded uppercase bg-slate-50 shrink-0 tracking-wider">
|
||||
{item.batch_no}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('total_amount', {
|
||||
header: 'TOTAL AMOUNT',
|
||||
size: 150,
|
||||
cell: info => {
|
||||
const val = info.getValue();
|
||||
return <span className="font-bold text-slate-800 text-[14px]">₹{val ? Number(val).toFixed(2) : '0.00'}</span>;
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('payment_method', {
|
||||
header: 'PAYMENT',
|
||||
size: 120,
|
||||
cell: info => {
|
||||
const val = info.getValue() || 'CASH';
|
||||
return (
|
||||
<span className="uppercase text-[11px] font-bold tracking-wide text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-md border border-emerald-100">
|
||||
{val}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
const History = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [historyList, setHistoryList] = useState<DispensingHistoryRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getDispensingHistory();
|
||||
if (data && data.history) {
|
||||
setHistoryList(data.history);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load history", err);
|
||||
setError("Failed to load dispensing history.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter based on search query
|
||||
const filteredHistory = useMemo(() => {
|
||||
return historyList.filter(item =>
|
||||
item.patient_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.dispensing_id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(item.prescription_id && item.prescription_id.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
}, [historyList, searchQuery]);
|
||||
|
||||
// Pagination logic
|
||||
const totalItems = filteredHistory.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
const currentData = filteredHistory.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
|
||||
{/* Top Header */}
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="p-2.5 bg-blue-100 text-blue-600 rounded-xl">
|
||||
<HistoryIcon size={22} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Dispensing History</h1>
|
||||
</div>
|
||||
<p className="text-slate-500 mt-1 ml-12">View all past orders and dispensed medicines.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Action Bar with Spotlight Search */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl 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 by patient name, prescription ID or Dispensing ID..."
|
||||
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={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="mt-4 px-4 py-2 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors font-medium"
|
||||
>
|
||||
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 font-medium">Loading history records...</p>
|
||||
</div>
|
||||
) : historyList.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
|
||||
<Clock className="w-16 h-16 mx-auto mb-6 opacity-20" />
|
||||
<h3 className="text-xl font-medium text-slate-700 mb-2">No Records Found</h3>
|
||||
<p className="max-w-md mx-auto">No dispensing history found. When you dispense orders, they will appear here.</p>
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm font-bold"
|
||||
>
|
||||
Refresh History
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={currentData}
|
||||
columns={columns}
|
||||
getRowId={(item) => item.dispensing_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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
@@ -0,0 +1,611 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Search, Bell, Settings, User, Package, Plus, AlertTriangle, X, Calendar, Hash, IndianRupee, Filter, 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 { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine } from '../services/api';
|
||||
|
||||
interface InventoryBatch {
|
||||
batch_no: string;
|
||||
product_id: string;
|
||||
brand_name: string;
|
||||
qty_on_hand: number;
|
||||
expiry: string;
|
||||
days_to_expiry: number;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<InventoryBatch>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('brand_name', {
|
||||
header: 'MEDICINE',
|
||||
size: 250,
|
||||
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('batch_no', {
|
||||
header: 'BATCH NO',
|
||||
size: 150,
|
||||
cell: info => (
|
||||
<span className="bg-slate-100 text-slate-600 px-3 py-1 rounded font-bold text-[11px] tracking-wide">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('qty_on_hand', {
|
||||
header: 'QTY ON HAND',
|
||||
size: 150,
|
||||
cell: info => (
|
||||
<span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('expiry', {
|
||||
header: 'EXPIRY DATE',
|
||||
size: 180,
|
||||
cell: info => {
|
||||
const date = new Date(info.getValue());
|
||||
return <span className="text-slate-600 font-medium text-[14px]">{date.toLocaleDateString()}</span>;
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('days_to_expiry', {
|
||||
header: 'DAYS TO EXPIRY',
|
||||
size: 150,
|
||||
cell: info => {
|
||||
const days = info.getValue();
|
||||
let colorClass = "text-emerald-600 bg-emerald-50";
|
||||
let displayText = `${days} Days`;
|
||||
|
||||
if (days < 0) {
|
||||
colorClass = "text-rose-700 bg-rose-100 border border-rose-200";
|
||||
displayText = "Expired";
|
||||
} else if (days === 0) {
|
||||
colorClass = "text-rose-600 bg-rose-50 border border-rose-200";
|
||||
displayText = "Expires Today";
|
||||
} else if (days < 30) {
|
||||
colorClass = "text-rose-600 bg-rose-50";
|
||||
} else if (days < 90) {
|
||||
colorClass = "text-amber-600 bg-amber-50";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded font-bold text-[12px] tracking-wide ${colorClass}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
const Inventory = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [inventory, setInventory] = useState<InventoryBatch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAddingStock, setIsAddingStock] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [drugOptions, setDrugOptions] = useState<any[]>([]);
|
||||
|
||||
const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock');
|
||||
const [newMedicine, setNewMedicine] = useState({
|
||||
brand_name: '',
|
||||
generic_name: '',
|
||||
manufacturer: '',
|
||||
form: 'tablet',
|
||||
strength: '',
|
||||
schedule: 'H'
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
supplier_id: '',
|
||||
invoice_no: '',
|
||||
product_id: '',
|
||||
batch_no: '',
|
||||
expiry: '',
|
||||
qty: '',
|
||||
purchase_price: ''
|
||||
});
|
||||
|
||||
const submitStock = async () => {
|
||||
try {
|
||||
setIsAddingStock(true);
|
||||
const timestamp = Date.now();
|
||||
|
||||
let finalProductId = formData.product_id;
|
||||
|
||||
if (stockMode === 'new') {
|
||||
const medPayload = {
|
||||
brand_name: newMedicine.brand_name,
|
||||
generic_name: newMedicine.generic_name,
|
||||
manufacturer: newMedicine.manufacturer,
|
||||
form: newMedicine.form,
|
||||
strength: newMedicine.strength,
|
||||
schedule: newMedicine.schedule
|
||||
};
|
||||
const medRes = await addMedicine(medPayload);
|
||||
if (medRes && medRes.product_id) {
|
||||
finalProductId = medRes.product_id;
|
||||
} else {
|
||||
throw new Error("Failed to generate Product ID for new medicine");
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
supplier_id: formData.supplier_id,
|
||||
invoice_no: formData.invoice_no || `INV-${timestamp}`,
|
||||
lines: [
|
||||
{
|
||||
batch_no: formData.batch_no.trim() || `BAT-${timestamp}`,
|
||||
product_id: finalProductId,
|
||||
expiry: formData.expiry,
|
||||
qty: formData.qty,
|
||||
purchase_price: formData.purchase_price
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await addStock(payload);
|
||||
|
||||
// Refresh the inventory data after successful addition
|
||||
await loadData();
|
||||
setIsModalOpen(false); // close the modal on success
|
||||
|
||||
// Reset state
|
||||
setStockMode('restock');
|
||||
setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H' });
|
||||
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
|
||||
|
||||
alert("Stock added successfully! Inventory count increased.");
|
||||
} catch (err) {
|
||||
console.error("Failed to add stock:", err);
|
||||
alert("Failed to add stock. Please try again.");
|
||||
} finally {
|
||||
setIsAddingStock(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
if (!localStorage.getItem('auth_token')) {
|
||||
await authenticate();
|
||||
}
|
||||
|
||||
let data: any;
|
||||
if (filterMode === 'low-stock') {
|
||||
data = await getLowStockInventory();
|
||||
} else if (filterMode === 'expiring') {
|
||||
data = await getExpiringInventory(); // defaults to 3650 for all, we should probably pass 30 here for 'near expiry'
|
||||
} else {
|
||||
data = await getExpiringInventory();
|
||||
}
|
||||
|
||||
if (data && data.batches) {
|
||||
// If filterMode is expiring, we actually want near expiry
|
||||
if (filterMode === 'expiring') {
|
||||
const nearExpiry = data.batches.filter((b: any) => b.days_to_expiry && b.days_to_expiry <= 30);
|
||||
setInventory(nearExpiry);
|
||||
} else {
|
||||
setInventory(data.batches);
|
||||
}
|
||||
}
|
||||
|
||||
const drugsData = await getDrugs();
|
||||
if (drugsData && drugsData.drugs) {
|
||||
setDrugOptions(drugsData.drugs);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response && err.response.status === 401) {
|
||||
try {
|
||||
await authenticate();
|
||||
const retryData = await getExpiringInventory();
|
||||
if (retryData && retryData.batches) {
|
||||
setInventory(retryData.batches);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error("Failed to fetch inventory after retry:", retryError);
|
||||
setError("Authentication failed. Please try logging in again.");
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch inventory:", err);
|
||||
setError("Failed to load inventory data.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [filterMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSetFilter = (e: any) => {
|
||||
if (e.detail) {
|
||||
setFilterMode(e.detail);
|
||||
}
|
||||
};
|
||||
window.addEventListener('setFilter', handleSetFilter);
|
||||
return () => window.removeEventListener('setFilter', handleSetFilter);
|
||||
}, []);
|
||||
|
||||
// Filter based on search query
|
||||
const filteredInventory = useMemo(() => {
|
||||
return inventory.filter(item =>
|
||||
item.brand_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.batch_no.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [inventory, searchQuery]);
|
||||
|
||||
// Pagination logic
|
||||
const totalItems = filteredInventory.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
const currentData = filteredInventory.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
|
||||
{/* Top Header */}
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
{/* Filter Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-2">
|
||||
<button
|
||||
onClick={() => setFilterMode('all')}
|
||||
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'all' ? 'bg-blue-600 border-blue-600 text-white shadow-lg shadow-blue-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-blue-300 hover:shadow-md'}`}
|
||||
style={{ padding: '24px' }}
|
||||
>
|
||||
<div className={`rounded-xl ${filterMode === 'all' ? 'bg-white/20' : 'bg-blue-50 text-blue-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold">All Inventory</span>
|
||||
<span className={`text-sm mt-1 ${filterMode === 'all' ? 'text-blue-100' : 'text-slate-500'}`}>View all stock</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setFilterMode('low-stock')}
|
||||
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'low-stock' ? 'bg-amber-500 border-amber-500 text-white shadow-lg shadow-amber-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-amber-300 hover:shadow-md'}`}
|
||||
style={{ padding: '24px' }}
|
||||
>
|
||||
<div className={`rounded-xl ${filterMode === 'low-stock' ? 'bg-white/20' : 'bg-amber-50 text-amber-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold">Low Stock</span>
|
||||
<span className={`text-sm mt-1 ${filterMode === 'low-stock' ? 'text-amber-100' : 'text-slate-500'}`}>Stock ≤ 50 units</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setFilterMode('expiring')}
|
||||
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'expiring' ? 'bg-rose-500 border-rose-500 text-white shadow-lg shadow-rose-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-rose-300 hover:shadow-md'}`}
|
||||
style={{ padding: '24px' }}
|
||||
>
|
||||
<div className={`rounded-xl ${filterMode === 'expiring' ? 'bg-white/20' : 'bg-rose-50 text-rose-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
|
||||
<Calendar size={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold">Near Expiry</span>
|
||||
<span className={`text-sm mt-1 ${filterMode === 'expiring' ? 'text-rose-100' : 'text-slate-500'}`}>Expiring in ≤ 30 days</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Bar with Spotlight Search */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl 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 batches by medicine or batch no..."
|
||||
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={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex-1 md:flex-none flex items-center justify-center gap-2 rounded-xl font-bold text-[13px] tracking-wide transition-colors shadow-sm bg-blue-600 hover:bg-blue-700 text-white"
|
||||
style={{ padding: '12px 24px' }}
|
||||
>
|
||||
<Plus size={18} />
|
||||
ADD STOCK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden" style={{ marginTop: '16px' }}>
|
||||
<div className="overflow-x-auto">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<AlertTriangle 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 inventory 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 Records Found</h3>
|
||||
<p className="max-w-md mx-auto">Your inventory is empty or no batches are expiring.</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.batch_no}
|
||||
/>
|
||||
)}
|
||||
</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 Stock Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="border-b border-slate-100 flex items-center justify-between shrink-0" style={{ padding: '20px 32px' }}>
|
||||
<h3 className="text-xl font-bold text-slate-800">Add Stock (GRN)</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
|
||||
style={{ padding: '8px' }}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-slate-200 bg-slate-50/50 px-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setStockMode('restock')}
|
||||
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'restock' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
||||
>
|
||||
Restock Existing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStockMode('new')}
|
||||
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'new' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
||||
>
|
||||
Add New Medicine
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 overflow-y-auto" style={{ padding: '24px', gap: '24px' }}>
|
||||
{stockMode === 'restock' ? (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Product ID</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
|
||||
<Package size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="product-options"
|
||||
value={formData.product_id}
|
||||
onChange={(e) => setFormData({ ...formData, product_id: e.target.value })}
|
||||
placeholder="Select or enter Product ID..."
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
<datalist id="product-options">
|
||||
{drugOptions.map(d => (
|
||||
<option key={d.product_id} value={d.product_id}>
|
||||
{d.brand_name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2 border-b border-slate-100 pb-2 mb-2">
|
||||
<h4 className="font-bold text-slate-800 text-sm">Medicine Details</h4>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Brand Name</label>
|
||||
<input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({...newMedicine, 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Generic Name</label>
|
||||
<input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({...newMedicine, 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Manufacturer</label>
|
||||
<input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({...newMedicine, 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Strength</label>
|
||||
<input type="text" value={newMedicine.strength} onChange={e => setNewMedicine({...newMedicine, 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 500mg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Form</label>
|
||||
<select value={newMedicine.form} onChange={e => setNewMedicine({...newMedicine, form: 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
|
||||
<option value="tablet">Tablet</option>
|
||||
<option value="capsule">Capsule</option>
|
||||
<option value="syrup">Syrup</option>
|
||||
<option value="injection">Injection</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Schedule</label>
|
||||
<select value={newMedicine.schedule} onChange={e => setNewMedicine({...newMedicine, schedule: 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
|
||||
<option value="H">Schedule H</option>
|
||||
<option value="H1">Schedule H1</option>
|
||||
<option value="X">Schedule X</option>
|
||||
<option value="OTC">OTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 border-b border-slate-100 pb-2 mb-2 mt-2">
|
||||
<h4 className="font-bold text-slate-800 text-sm">Stock Details</h4>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GRN Details */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Supplier ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.supplier_id}
|
||||
onChange={(e) => setFormData({ ...formData, supplier_id: e.target.value })}
|
||||
placeholder="sup_001"
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Invoice No</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.invoice_no}
|
||||
onChange={(e) => setFormData({ ...formData, invoice_no: e.target.value })}
|
||||
placeholder="Auto-generated if empty"
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px' }}
|
||||
/>
|
||||
</div>
|
||||
{/* Batch No & Expiry Date */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Batch No</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
|
||||
<Hash size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.batch_no}
|
||||
onChange={(e) => setFormData({ ...formData, batch_no: e.target.value })}
|
||||
placeholder="Auto-generated if empty"
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Expiry Date</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
|
||||
<Calendar size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expiry}
|
||||
onChange={(e) => setFormData({ ...formData, expiry: 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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Quantity</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Hash size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.qty}
|
||||
onChange={(e) => setFormData({ ...formData, qty: e.target.value })}
|
||||
placeholder="Enter quantity"
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purchase Price */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Purchase Price</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-emerald-500 transition-colors">
|
||||
<IndianRupee size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.purchase_price}
|
||||
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
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 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 bg-slate-50 flex justify-end shrink-0" style={{ padding: '20px 32px', gap: '16px' }}>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="shrink-0 text-[15px] font-bold text-slate-600 hover:bg-slate-200 bg-slate-200/50 rounded-xl transition-colors"
|
||||
style={{ padding: '12px 24px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitStock}
|
||||
disabled={isAddingStock}
|
||||
className={`shrink-0 text-[15px] font-bold text-white rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2 ${isAddingStock ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{ padding: '12px 32px' }}
|
||||
>
|
||||
{isAddingStock ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Stock'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inventory;
|
||||
@@ -0,0 +1,611 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
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 { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder } 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: 'Price',
|
||||
size: 120,
|
||||
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 [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: '',
|
||||
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 resetDispenseModal = () => {
|
||||
setIsDispenseModalOpen(false);
|
||||
setDispenseStep(1);
|
||||
setOrderDraftId(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);
|
||||
await dispenseOrder(orderDraftId, {
|
||||
subject_ref: patientName,
|
||||
prescriber_name: prescriberName,
|
||||
payment_method: 'cash'
|
||||
});
|
||||
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: '', 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) => ({
|
||||
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: parseFloat(drug.mrp) || 0,
|
||||
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.mrp) || 0),
|
||||
manufacture: drug.manufacturer || 'Unknown',
|
||||
type: drug.form || 'Unknown'
|
||||
}));
|
||||
setInventory(mappedData);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
if (!localStorage.getItem('auth_token')) {
|
||||
await authenticate();
|
||||
}
|
||||
|
||||
const data = await getDrugs();
|
||||
if (data && data.drugs) {
|
||||
mapAndSetData(data.drugs);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
try {
|
||||
await authenticate();
|
||||
const retryData = await getDrugs();
|
||||
if (retryData && retryData.drugs) {
|
||||
mapAndSetData(retryData.drugs);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error("Failed to fetch drugs after retry:", retryError);
|
||||
setError("Authentication failed. Please try logging in again.");
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch drugs:", error);
|
||||
setError("Failed to connect to the server. Please try again later.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Filter inventory based on search query
|
||||
const filteredInventory = useMemo(() => {
|
||||
return inventory.filter(item =>
|
||||
item.medicine.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.subtitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.manufacture.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [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">
|
||||
<header className="mb-6 shrink-0 flex items-center justify-between">
|
||||
<div>
|
||||
<h1>Pharmacy Overview</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0" style={{ marginBottom: '32px' }}>
|
||||
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<Package className="text-blue-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 text-[13px] font-medium mb-1">Total Medicines</p>
|
||||
<h3 className="text-2xl font-bold text-slate-900">{inventory.length}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center shrink-0">
|
||||
<AlertCircle className="text-red-500" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 text-[13px] font-medium mb-1">Low Stock Alerts</p>
|
||||
<h3 className="text-2xl font-bold text-slate-900">{lowStockCount}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Layers className="text-indigo-500" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 text-[13px] font-medium mb-1">Unique Categories</p>
|
||||
<h3 className="text-2xl font-bold 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 max-w-2xl 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={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsDispenseModalOpen(true)}
|
||||
className="flex items-center gap-2 bg-emerald-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-emerald-700 transition-colors shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Package size={18} strokeWidth={2.5} />
|
||||
Manual Dispense
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-blue-700 transition-colors shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.5} />
|
||||
Add Medicine
|
||||
</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>
|
||||
|
||||
<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</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={e => setPatientName(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. John Doe"
|
||||
/>
|
||||
</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>
|
||||
<CustomSelect
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const item = inventory.find(i => i.id === e.target.value);
|
||||
if (item && !selectedItems.find(i => i.product_id === item.id)) {
|
||||
setSelectedItems([...selectedItems, { product_id: item.id, name: item.medicine, qty: 1, price: item.rawPrice }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>Select a medicine to add...</option>
|
||||
{inventory.filter(i => i.stock > 0).map(item => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.medicine} (Stock: {item.stock})
|
||||
</option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pharmacy;
|
||||
@@ -0,0 +1,603 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye } from 'lucide-react';
|
||||
import { uploadPrescription, getPrescriptionDetails, confirmPrescription, precheckOrder, dispenseOrder, getAllPrescriptions, getDrugs } from '../services/api';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
|
||||
const Prescriptions = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
// State for the uploaded prescription processing
|
||||
const [recordId, setRecordId] = useState<string | null>(null);
|
||||
const [prescriptionData, setPrescriptionData] = useState<any>(null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
// Confirmation state
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
|
||||
// Dispensing state
|
||||
const [isPrechecking, setIsPrechecking] = useState(false);
|
||||
const [precheckResult, setPrecheckResult] = useState<any>(null);
|
||||
const [isDispensing, setIsDispensing] = useState(false);
|
||||
const [dispenseSuccess, setDispenseSuccess] = useState(false);
|
||||
const [paymentMethod, setPaymentMethod] = useState('cash');
|
||||
const [drugOptions, setDrugOptions] = useState<any[]>([]);
|
||||
|
||||
// Queue State
|
||||
const [prescriptionsList, setPrescriptionsList] = useState<any[]>([]);
|
||||
const [queueCurrentPage, setQueueCurrentPage] = useState(1);
|
||||
const [queueItemsPerPage, setQueueItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrescriptions();
|
||||
getDrugs().then(data => {
|
||||
if (data && data.drugs) setDrugOptions(data.drugs);
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const fetchPrescriptions = async () => {
|
||||
try {
|
||||
const data = await getAllPrescriptions();
|
||||
if (data && data.prescriptions) {
|
||||
setPrescriptionsList(data.prescriptions);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load queue", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
setUploadError(null);
|
||||
setPrescriptionData(null);
|
||||
setIsConfirmed(false);
|
||||
setPrecheckResult(null);
|
||||
setDispenseSuccess(false);
|
||||
setPaymentMethod('cash');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
const response = await uploadPrescription(file);
|
||||
setRecordId(response.id); // Typically returns the new ID
|
||||
|
||||
// Start polling
|
||||
pollForDetails(response.id);
|
||||
} catch (err: any) {
|
||||
console.error("Upload failed", err);
|
||||
setUploadError("Failed to upload prescription. Please try again.");
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollForDetails = async (id: string) => {
|
||||
setIsPolling(true);
|
||||
let attempts = 0;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
attempts++;
|
||||
const data = await getPrescriptionDetails(id);
|
||||
|
||||
if (data.status === 'completed' || data.status === 'requires_review' || data.status === 'pending_review') {
|
||||
clearInterval(interval);
|
||||
setPrescriptionData(data);
|
||||
setIsPolling(false);
|
||||
setIsUploading(false);
|
||||
} else if (data.status === 'failed') {
|
||||
clearInterval(interval);
|
||||
setUploadError("AI Extraction failed. Please try a clearer image.");
|
||||
setIsPolling(false);
|
||||
setIsUploading(false);
|
||||
} else if (attempts > 30) {
|
||||
// Timeout after ~30 seconds (assuming 1s interval)
|
||||
clearInterval(interval);
|
||||
setUploadError("Processing timed out. Please try again.");
|
||||
setIsPolling(false);
|
||||
setIsUploading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Polling error", err);
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<any>();
|
||||
|
||||
const queueColumns = React.useMemo(() => [
|
||||
columnHelper.accessor('id', {
|
||||
header: 'ID',
|
||||
size: 150,
|
||||
cell: info => <span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 font-bold tracking-wide">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('created_at', {
|
||||
header: 'DATE',
|
||||
size: 200,
|
||||
cell: info => <span className="text-slate-600 font-medium text-[13px]">{new Date(info.getValue()).toLocaleString()}</span>
|
||||
}),
|
||||
columnHelper.accessor('status', {
|
||||
header: 'STATUS',
|
||||
size: 150,
|
||||
cell: info => {
|
||||
const status = info.getValue();
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-md text-[11px] font-bold tracking-wide uppercase ${
|
||||
status === 'confirmed' ? 'bg-emerald-100 text-emerald-700 border border-emerald-200' :
|
||||
status === 'processing' ? 'bg-amber-100 text-amber-700 border border-amber-200' :
|
||||
'bg-blue-100 text-blue-700 border border-blue-200'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'action',
|
||||
header: 'ACTION',
|
||||
size: 100,
|
||||
cell: info => {
|
||||
const rx = info.row.original;
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRecordId(rx.id);
|
||||
if (rx.status === 'confirmed' && rx.confirmed_data) {
|
||||
setPrescriptionData({ extracted_data: rx.confirmed_data.extracted_data || rx.confirmed_data });
|
||||
setIsConfirmed(true);
|
||||
} else if (rx.extracted_data) {
|
||||
setPrescriptionData({ extracted_data: rx.extracted_data });
|
||||
setIsConfirmed(false);
|
||||
} else {
|
||||
pollForDetails(rx.id);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-[13px] font-bold transition-colors shadow-sm"
|
||||
>
|
||||
<Eye size={14} /> View
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}),
|
||||
], [setRecordId, setPrescriptionData, setIsConfirmed]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!recordId || !prescriptionData) return;
|
||||
|
||||
try {
|
||||
setIsConfirming(true);
|
||||
const items = (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((item: any) => ({
|
||||
...item,
|
||||
explicitly_confirmed: true
|
||||
}));
|
||||
|
||||
await confirmPrescription(recordId, {
|
||||
extracted_data: {
|
||||
...prescriptionData.extracted_data,
|
||||
items: items
|
||||
},
|
||||
items: items
|
||||
});
|
||||
setIsConfirmed(true);
|
||||
} catch (err: any) {
|
||||
console.error("Confirmation failed", err);
|
||||
if (err.response?.data?.message) {
|
||||
alert("Failed: " + err.response.data.message);
|
||||
} else {
|
||||
alert("Failed to confirm prescription. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrecheck = async () => {
|
||||
if (!recordId || !prescriptionData) return;
|
||||
|
||||
try {
|
||||
setIsPrechecking(true);
|
||||
|
||||
const payload = {
|
||||
clinical_context: {
|
||||
patient_id: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown',
|
||||
allergies: [],
|
||||
conditions: []
|
||||
},
|
||||
items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => {
|
||||
const name = m.parsed?.brand || m.name || m.verbatim || '';
|
||||
|
||||
let matchedDrug = drugOptions.find(d =>
|
||||
name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
|
||||
d.brand_name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
let productId = matchedDrug ? matchedDrug.product_id : 'prd_4410'; // Default fallback if no match
|
||||
|
||||
|
||||
let qty = m.parsed?.qty || m.quantity;
|
||||
if (!qty || isNaN(qty)) {
|
||||
const doseStr = m.parsed?.dose || m.dosage || '';
|
||||
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
|
||||
if (match) {
|
||||
qty = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
|
||||
} else {
|
||||
qty = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
product_id: productId,
|
||||
qty: qty,
|
||||
dosage_instructions: m.parsed?.dose || m.dosage || ''
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const result = await precheckOrder(recordId, payload);
|
||||
setPrecheckResult(result);
|
||||
} catch (err) {
|
||||
console.error("Precheck failed", err);
|
||||
alert("Failed to run safety pre-checks.");
|
||||
} finally {
|
||||
setIsPrechecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDispense = async () => {
|
||||
if (!recordId || !prescriptionData) return;
|
||||
|
||||
try {
|
||||
setIsDispensing(true);
|
||||
|
||||
const payload = {
|
||||
subject_ref: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown',
|
||||
prescriber_name: prescriptionData.extracted_data.prescriber?.name || 'Unknown Prescriber',
|
||||
items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => {
|
||||
const name = m.parsed?.brand || m.name || m.verbatim || '';
|
||||
|
||||
let matchedDrug = drugOptions.find(d =>
|
||||
name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
|
||||
d.brand_name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
let productId = matchedDrug ? matchedDrug.product_id : 'prd_4410';
|
||||
|
||||
|
||||
let qty = m.parsed?.qty || m.quantity;
|
||||
if (!qty || isNaN(qty)) {
|
||||
// Try to extract qty from dosage like 1-0-1
|
||||
const doseStr = m.parsed?.dose || m.dosage || '';
|
||||
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
|
||||
if (match) {
|
||||
qty = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
|
||||
} else {
|
||||
qty = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
product_id: productId,
|
||||
qty: qty
|
||||
};
|
||||
}),
|
||||
payment_method: paymentMethod
|
||||
};
|
||||
|
||||
await dispenseOrder(recordId, payload);
|
||||
setDispenseSuccess(true);
|
||||
alert("Order successfully dispensed! Stock has been updated.");
|
||||
} catch (err) {
|
||||
console.error("Dispense failed", err);
|
||||
alert("Failed to dispense order. Check inventory levels.");
|
||||
} finally {
|
||||
setIsDispensing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalQueueItems = prescriptionsList.length;
|
||||
const totalQueuePages = Math.max(1, Math.ceil(totalQueueItems / queueItemsPerPage));
|
||||
const currentQueueData = prescriptionsList.slice(
|
||||
(queueCurrentPage - 1) * queueItemsPerPage,
|
||||
queueCurrentPage * queueItemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto flex flex-col w-full p-8">
|
||||
<header className="mb-8">
|
||||
<h1>AI Prescription Processing</h1>
|
||||
<p className="text-slate-500 mt-2">Upload a scanned prescription and let the AI extract the medical details.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
|
||||
{/* Upload Column */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6 h-fit">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-800">Upload Scan</h2>
|
||||
|
||||
<div className="border-2 border-dashed border-blue-500/30 rounded-2xl p-10 flex flex-col items-center justify-center text-center bg-blue-50 hover:bg-blue-100/50 transition-colors">
|
||||
<UploadCloud size={48} className="text-blue-400 mb-4" />
|
||||
<p className="text-slate-700 font-bold mb-1">Drag and drop your prescription here</p>
|
||||
<p className="text-sm text-slate-500 mb-6">Supports JPG, PNG, PDF (Max 5MB)</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,application/pdf"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
||||
style={{ padding: '10px 24px', display: 'inline-block', lineHeight: '24px' }}
|
||||
>
|
||||
Select File
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="mt-6 flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-blue-500" size={24} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isUploading && !isPolling && !prescriptionData && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-bold rounded-lg transition-colors shadow-sm"
|
||||
style={{ padding: '8px 16px' }}
|
||||
>
|
||||
Process with AI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-xl flex items-center gap-3">
|
||||
<AlertTriangle size={20} />
|
||||
<p className="text-sm font-bold">{uploadError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Column */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-800">Extraction Results</h2>
|
||||
|
||||
{(isUploading || isPolling) && !prescriptionData && (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 size={40} className="text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-slate-700 font-bold">AI is analyzing the prescription...</p>
|
||||
<p className="text-sm text-slate-500 mt-2">Extracting doctor details, medicines, and dosages.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUploading && !isPolling && !prescriptionData && (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center opacity-50">
|
||||
<FileText size={48} className="text-slate-300 mb-4" />
|
||||
<p className="text-slate-500 font-bold">No results yet.</p>
|
||||
<p className="text-sm text-slate-400 font-medium mt-1">Upload a prescription to see AI extraction.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prescriptionData && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl flex items-start gap-3">
|
||||
<CheckCircle className="text-emerald-500 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-bold text-emerald-800">Extraction Complete</p>
|
||||
<p className="text-sm font-medium text-emerald-600 mt-1">Please review the details below before confirming for dispense.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Patient Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''}
|
||||
onChange={(e) => setPrescriptionData({
|
||||
...prescriptionData,
|
||||
extracted_data: {
|
||||
...prescriptionData.extracted_data,
|
||||
patient: { ...prescriptionData.extracted_data?.patient, name: e.target.value },
|
||||
patient_name: e.target.value
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Doctor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prescriptionData.extracted_data?.prescriber?.name || prescriptionData.extracted_data?.doctor_name || ''}
|
||||
onChange={(e) => setPrescriptionData({
|
||||
...prescriptionData,
|
||||
extracted_data: {
|
||||
...prescriptionData.extracted_data,
|
||||
prescriber: { ...prescriptionData.extracted_data?.prescriber, name: e.target.value },
|
||||
doctor_name: e.target.value
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Prescribed Medicines</label>
|
||||
<div className="space-y-2">
|
||||
{(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).map((med: any, idx: number) => (
|
||||
<div key={idx} className="p-3 bg-slate-50 border border-slate-200 rounded-lg flex justify-between items-center">
|
||||
<span className="font-bold text-slate-800">{med.parsed?.brand || med.name || med.verbatim}</span>
|
||||
<span className="text-sm font-bold text-blue-600 bg-blue-100/50 px-2.5 py-1 rounded-md">{med.parsed?.dose || med.dosage}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && (
|
||||
<p className="text-sm text-slate-500 italic">No medicines detected.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-100 flex justify-end">
|
||||
{isConfirmed ? (
|
||||
<div className="flex gap-4">
|
||||
<button disabled className="bg-emerald-100 text-emerald-700 font-bold rounded-xl flex items-center gap-2" style={{ padding: '10px 16px' }}>
|
||||
<CheckCircle size={18} /> Verified
|
||||
</button>
|
||||
|
||||
{!precheckResult && !dispenseSuccess && (
|
||||
<button
|
||||
onClick={handlePrecheck}
|
||||
disabled={isPrechecking}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center gap-2"
|
||||
style={{ padding: '10px 24px' }}
|
||||
>
|
||||
{isPrechecking ? <Loader2 className="animate-spin" size={18} /> : 'Run Safety Pre-Check'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isConfirming}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center gap-2"
|
||||
style={{ padding: '10px 24px' }}
|
||||
>
|
||||
{isConfirming ? <Loader2 className="animate-spin" size={18} /> : 'Confirm & Verify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{precheckResult && !dispenseSuccess && (
|
||||
<div className="mt-6 p-4 bg-slate-50 border border-slate-200 rounded-xl animate-in fade-in">
|
||||
<h3 className="font-bold text-slate-800 mb-2">Safety Pre-Check Results</h3>
|
||||
<div className="space-y-2 mb-4">
|
||||
{precheckResult.checks?.map((check: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
{check.passed ? <CheckCircle className="text-emerald-500" size={16} /> : <AlertTriangle className="text-amber-500" size={16} />}
|
||||
<span className={check.passed ? 'text-slate-600' : 'text-amber-700 font-medium'}>{check.message || check.type}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!precheckResult.checks || precheckResult.checks.length === 0) && (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600 font-medium">
|
||||
<CheckCircle size={16} /> All clinical safety gates passed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-slate-100">
|
||||
<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>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Payment Method</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="upi">UPI / Scanner</option>
|
||||
<option value="card">Credit/Debit Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDispense}
|
||||
disabled={isDispensing}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2"
|
||||
style={{ padding: '12px 16px' }}
|
||||
>
|
||||
{isDispensing ? <Loader2 className="animate-spin" size={18} /> : <><Pill size={18} /> Execute Dispense</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dispenseSuccess && (
|
||||
<div className="mt-6 p-6 bg-emerald-50 border border-emerald-200 rounded-xl text-center animate-in zoom-in-95">
|
||||
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle className="text-emerald-500" size={24} />
|
||||
</div>
|
||||
<h3 className="font-bold text-emerald-800 text-lg">Order Dispensed Successfully</h3>
|
||||
<p className="text-emerald-600 mt-1">Inventory has been deducted and audit logs updated.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px 0' }}></div>
|
||||
|
||||
{/* Queue Section */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl flex flex-col overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">Prescription Queue</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Recent uploads waiting for processing or review.</p>
|
||||
</div>
|
||||
<button onClick={fetchPrescriptions} className="bg-white border border-slate-200 text-slate-600 hover:text-blue-600 hover:border-blue-200 hover:bg-blue-50 text-sm font-bold rounded-lg transition-colors shadow-sm" style={{ padding: '8px 16px', lineHeight: '20px' }}>
|
||||
Refresh Queue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto p-0">
|
||||
{prescriptionsList.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium text-slate-600">No prescriptions found in queue.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={currentQueueData}
|
||||
columns={queueColumns}
|
||||
getRowId={(item) => item.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalQueueItems > 0 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/30">
|
||||
<Pagination
|
||||
currentPage={queueCurrentPage}
|
||||
totalPages={totalQueuePages}
|
||||
totalItems={totalQueueItems}
|
||||
itemsPerPage={queueItemsPerPage}
|
||||
itemsPerPageOptions={[5, 10, 25, 50]}
|
||||
onPageChange={(page) => setQueueCurrentPage(page)}
|
||||
onItemsPerPageChange={(limit) => {
|
||||
setQueueItemsPerPage(limit);
|
||||
setQueueCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Prescriptions;
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { BookOpen, Search, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { getH1Register } from '../services/api';
|
||||
|
||||
interface H1RegisterRecord {
|
||||
id: string;
|
||||
dispensing_id: string;
|
||||
patient_name: string;
|
||||
prescriber_name: string;
|
||||
drug_name: string;
|
||||
batch_no: string;
|
||||
qty: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<H1RegisterRecord>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('id', {
|
||||
header: 'ENTRY ID',
|
||||
size: 100,
|
||||
cell: info => (
|
||||
<span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 font-bold">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('created_at', {
|
||||
header: 'DATE',
|
||||
size: 150,
|
||||
cell: info => {
|
||||
const date = new Date(info.getValue());
|
||||
return <span className="text-slate-600 font-medium text-[13px]">{date.toLocaleString()}</span>;
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('patient_name', {
|
||||
header: 'PATIENT NAME',
|
||||
size: 180,
|
||||
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('prescriber_name', {
|
||||
header: 'PRESCRIBER',
|
||||
size: 180,
|
||||
cell: info => <span className="text-slate-700 text-[13px]">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('drug_name', {
|
||||
header: 'DRUG (GENERIC)',
|
||||
size: 250,
|
||||
cell: info => <span className="font-bold text-slate-800 text-[13px]">{info.getValue()}</span>
|
||||
}),
|
||||
columnHelper.accessor('batch_no', {
|
||||
header: 'BATCH',
|
||||
size: 100,
|
||||
cell: info => (
|
||||
<span className="text-[11px] font-bold text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded uppercase bg-slate-50">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('qty', {
|
||||
header: 'QTY',
|
||||
size: 80,
|
||||
cell: info => <span className="font-bold text-slate-800">{info.getValue()}</span>
|
||||
}),
|
||||
];
|
||||
|
||||
const Registers = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [records, setRecords] = useState<H1RegisterRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords();
|
||||
}, []);
|
||||
|
||||
const fetchRecords = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getH1Register();
|
||||
if (data && data.records) {
|
||||
setRecords(data.records);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load H1 register", err);
|
||||
setError("Failed to load statutory registers.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return records.filter(item =>
|
||||
item.patient_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.drug_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.prescriber_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [records, searchQuery]);
|
||||
|
||||
const totalItems = filteredRecords.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
const currentData = filteredRecords.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="p-2.5 bg-purple-100 text-purple-600 rounded-xl">
|
||||
<BookOpen size={22} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Statutory Registers</h1>
|
||||
</div>
|
||||
<p className="text-slate-500 mt-1 ml-12">View Schedule H1 and other compliant pharmacy registers.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button className="bg-slate-800 hover:bg-slate-900 text-white font-medium rounded-lg shadow-sm transition-colors text-sm" style={{ padding: '8px 20px', lineHeight: '20px' }}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl 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 by patient, drug, or doctor..."
|
||||
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={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-500">Register:</span>
|
||||
<select className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="h1">Schedule H1 Register</option>
|
||||
<option value="sales" disabled>Sales Register (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={fetchRecords}
|
||||
className="mt-4 px-4 py-2 bg-purple-50 text-purple-600 rounded-md hover:bg-purple-100 transition-colors font-medium"
|
||||
>
|
||||
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-purple-600 rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-slate-500 font-medium">Loading H1 register...</p>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
|
||||
<Clock className="w-16 h-16 mx-auto mb-6 opacity-20" />
|
||||
<h3 className="text-xl font-medium text-slate-700 mb-2">No H1 Entries Found</h3>
|
||||
<p className="max-w-md mx-auto">When Schedule H1 drugs are dispensed, they will automatically appear in this immutable register.</p>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Registers;
|
||||
Reference in New Issue
Block a user