feat: implement core pharmacy management dashboard and navigation infrastructure

This commit is contained in:
2026-08-03 10:29:58 +05:30
parent bd800a3bab
commit 8c25b84616
18 changed files with 2184 additions and 184 deletions
+3 -1
View File
@@ -10,12 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.18.1", "axios": "^1.18.1",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1" "react-router-dom": "^7.18.1",
"recharts": "^3.10.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
+45 -14
View File
@@ -1,36 +1,67 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Pharmacy from './pages/Pharmacy'; import Pharmacy from './pages/Pharmacy';
import Inventory from './pages/Inventory'; import Inventory from './pages/Inventory';
import Patients from './pages/Patients';
import Prescriptions from './pages/Prescriptions'; import Prescriptions from './pages/Prescriptions';
import History from './pages/History'; import History from './pages/History';
import Registers from './pages/Registers'; import Registers from './pages/Registers';
import StockMovements from './pages/StockMovements';
import Suppliers from './pages/Suppliers';
import Settings from './pages/Settings';
import ProfileSettings from './pages/settings/ProfileSettings';
import SecuritySettings from './pages/settings/SecuritySettings';
import Login from './pages/Login';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import { VoiceAssistant } from './components/VoiceAssistant'; import { VoiceAssistant } from './components/VoiceAssistant';
import { AuthProvider, useAuth } from './contexts/AuthContext';
const ProtectedLayout = () => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return (
<div className="app-container">
<Sidebar />
<main className="main-content">
<Outlet />
</main>
<VoiceAssistant />
</div>
);
};
function App() { function App() {
return ( return (
<Router> <AuthProvider>
<div className="app-container"> <Router>
<Sidebar /> <Routes>
<main className="main-content"> <Route path="/login" element={<Login />} />
<Routes>
<Route element={<ProtectedLayout />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/pharmacy" element={<Pharmacy />} /> <Route path="/pharmacy" element={<Pharmacy />} />
<Route path="/patients" element={<div>Patients View</div>} /> <Route path="/patients" element={<Patients />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
<Route path="/suppliers" element={<Suppliers />} />
<Route path="/prescriptions" element={<Prescriptions />} /> <Route path="/prescriptions" element={<Prescriptions />} />
<Route path="/registers" element={<Registers />} /> <Route path="/registers" element={<Registers />} />
<Route path="/settings" element={<div>Settings View</div>} /> <Route path="/stock-movements" element={<StockMovements />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes> </Route>
</main>
{/* Global AI Voice Assistant */} {/* Catch all route */}
<VoiceAssistant /> <Route path="*" element={<Navigate to="/login" replace />} />
</div> </Routes>
</Router> </Router>
</AuthProvider>
); );
} }
+45 -6
View File
@@ -1,8 +1,24 @@
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, Pill, Settings, HeartPulse, Box, Clock, ClipboardList } from 'lucide-react'; import { LayoutDashboard, Users, Pill, Settings, HeartPulse, Box, Clock, ClipboardList, Activity, Truck, LogOut } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { logoutPharmacist } from '../services/api';
const Sidebar: React.FC = () => { const Sidebar: React.FC = () => {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await logoutPharmacist();
} catch (error) {
console.error('Failed to call logout API, proceeding to clear local state', error);
} finally {
logout();
navigate('/login');
}
};
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-logo"> <div className="sidebar-logo">
@@ -35,7 +51,6 @@ const Sidebar: React.FC = () => {
<span>Prescriptions</span> <span>Prescriptions</span>
</NavLink> </NavLink>
{/*
<NavLink <NavLink
to="/patients" to="/patients"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
@@ -43,7 +58,6 @@ const Sidebar: React.FC = () => {
<Users className="nav-item-icon" /> <Users className="nav-item-icon" />
<span>Patients</span> <span>Patients</span>
</NavLink> </NavLink>
*/}
<NavLink <NavLink
to="/registers" to="/registers"
@@ -61,6 +75,14 @@ const Sidebar: React.FC = () => {
<span>History</span> <span>History</span>
</NavLink> </NavLink>
<NavLink
to="/stock-movements"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Activity className="nav-item-icon" />
<span>Stock Movements</span>
</NavLink>
<NavLink <NavLink
to="/inventory" to="/inventory"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
@@ -68,9 +90,15 @@ const Sidebar: React.FC = () => {
<Box className="nav-item-icon" /> <Box className="nav-item-icon" />
<span>Inventory</span> <span>Inventory</span>
</NavLink> </NavLink>
</nav>
<div className="sidebar-footer" style={{ marginTop: 'auto' }}> <NavLink
to="/suppliers"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Truck className="nav-item-icon" />
<span>Suppliers</span>
</NavLink>
<NavLink <NavLink
to="/settings" to="/settings"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
@@ -78,6 +106,17 @@ const Sidebar: React.FC = () => {
<Settings className="nav-item-icon" /> <Settings className="nav-item-icon" />
<span>Settings</span> <span>Settings</span>
</NavLink> </NavLink>
</nav>
<div className="sidebar-footer" style={{ marginTop: 'auto' }}>
<button
onClick={handleLogout}
className="nav-item"
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit', padding: '0.75rem 1rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}
>
<LogOut className="nav-item-icon" />
<span style={{ fontSize: '1rem' }}>Logout</span>
</button>
</div> </div>
</aside> </aside>
); );
+10
View File
@@ -121,6 +121,16 @@ export const VoiceAssistant: React.FC = () => {
navigate('/prescriptions'); navigate('/prescriptions');
} else if (result.actionCommand === 'NAVIGATE_CATALOGUE') { } else if (result.actionCommand === 'NAVIGATE_CATALOGUE') {
navigate('/inventory'); navigate('/inventory');
} else if (result.actionCommand === 'NAVIGATE_PATIENTS') {
navigate('/patients');
} else if (result.actionCommand === 'NAVIGATE_SUPPLIERS') {
navigate('/suppliers');
} else if (result.actionCommand === 'NAVIGATE_REGISTERS') {
navigate('/registers');
} else if (result.actionCommand === 'NAVIGATE_STOCK_MOVEMENTS') {
navigate('/stock-movements');
} else if (result.actionCommand === 'NAVIGATE_HISTORY') {
navigate('/history');
} }
} catch (error) { } catch (error) {
console.error("AI command error:", error); console.error("AI command error:", error);
+40
View File
@@ -0,0 +1,40 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
login: () => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
return !!localStorage.getItem('auth_token');
});
const login = () => {
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('actor_id');
localStorage.removeItem('user');
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+131 -47
View File
@@ -1,9 +1,16 @@
import { Activity, Package, Pill, Users } from 'lucide-react'; import { Activity, Package, Pill, Users } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getExpiringInventory, authenticate, getAuditLogs } from '../services/api'; import { authenticate, getAuditLogs, getDashboardMetrics } from '../services/api';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const Dashboard = () => { const Dashboard = () => {
const [inventoryCount, setInventoryCount] = useState<number | string>('...'); const [metrics, setMetrics] = useState({
totalPatients: '...',
totalPrescriptions: '...',
inventoryItems: '...',
dispensedToday: '...',
trend: [] as any[]
});
const [recentLogs, setRecentLogs] = useState<any[]>([]); const [recentLogs, setRecentLogs] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
@@ -12,9 +19,16 @@ const Dashboard = () => {
if (!localStorage.getItem('auth_token')) { if (!localStorage.getItem('auth_token')) {
await authenticate(); await authenticate();
} }
const data = await getExpiringInventory();
if (data && data.batches) { const metricsData = await getDashboardMetrics();
setInventoryCount(data.batches.length); if (metricsData) {
setMetrics({
totalPatients: metricsData.totalPatients.toString(),
totalPrescriptions: metricsData.totalPrescriptions.toString(),
inventoryItems: metricsData.inventoryItems.toString(),
dispensedToday: metricsData.dispensedToday.toString(),
trend: metricsData.trend || []
});
} }
const logsData = await getAuditLogs(); const logsData = await getAuditLogs();
@@ -23,70 +37,140 @@ const Dashboard = () => {
} }
} catch (err) { } catch (err) {
console.error("Failed to load dashboard data", err); console.error("Failed to load dashboard data", err);
setInventoryCount('Error'); setMetrics({
totalPatients: 'Error',
totalPrescriptions: 'Error',
inventoryItems: 'Error',
dispensedToday: 'Error',
trend: []
});
} }
}; };
fetchData(); fetchData();
}, []); }, []);
const stats = [ const stats = [
{ label: 'Total Patients', value: '1,234', icon: Users, color: 'text-blue-400' }, { label: 'Total Patients', value: metrics.totalPatients, icon: Users, color: 'text-blue-600', gradient: 'from-blue-400 to-blue-600' },
{ label: 'Prescriptions', value: '856', icon: Activity, color: 'text-green-400' }, { label: 'Prescriptions', value: metrics.totalPrescriptions, icon: Activity, color: 'text-emerald-600', gradient: 'from-emerald-400 to-emerald-600' },
{ label: 'Inventory Items', value: inventoryCount, icon: Package, color: 'text-purple-400' }, { label: 'Inventory Items', value: metrics.inventoryItems, icon: Package, color: 'text-purple-600', gradient: 'from-purple-400 to-purple-600' },
{ label: 'Dispensed Today', value: '142', icon: Pill, color: 'text-pink-400' }, { label: 'Dispensed Today', value: metrics.dispensedToday, icon: Pill, color: 'text-pink-600', gradient: 'from-pink-400 to-pink-600' },
]; ];
return ( return (
<div className="p-8"> <div className="p-8 max-w-[1600px] mx-auto animate-in fade-in duration-700 slide-in-from-bottom-4">
<header className="mb-8"> <header className="mb-8">
<h1>AI Pharmacy Dashboard</h1> <h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">AI Pharmacy Dashboard</h1>
<p className="text-gray-400 mt-2">Welcome to your intelligent pharmacy management system.</p> <p className="text-slate-500 mt-2 font-medium">Welcome to your intelligent pharmacy management system.</p>
</header> </header>
<div className="grid-cols-3"> {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" style={{ marginBottom: '3rem' }}>
{stats.map((stat, idx) => ( {stats.map((stat, idx) => (
<div key={idx} className="glass-panel flex items-center gap-4"> <div key={idx} className="glass-panel relative overflow-hidden group hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
<div className={`p-3 rounded-full bg-white/10 ${stat.color}`}> {/* Glow Effect */}
<stat.icon size={24} /> <div className={`absolute -top-10 -right-10 w-40 h-40 bg-gradient-to-br ${stat.gradient} opacity-10 rounded-full blur-3xl group-hover:opacity-20 transition-opacity duration-500`}></div>
</div>
<div> <div className="flex items-center gap-5 relative z-10">
<p className="text-sm text-gray-400">{stat.label}</p> <div className={`p-4 rounded-2xl bg-white shadow-sm border border-slate-100 ${stat.color} group-hover:scale-110 transition-transform duration-300`}>
<p className="text-2xl font-bold">{stat.value}</p> <stat.icon size={26} strokeWidth={2.5} />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">{stat.label}</p>
<p className="text-3xl font-extrabold text-slate-800 leading-none">{stat.value}</p>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-8 grid-cols-3"> {/* Main Content Grid */}
<div className="glass-panel" style={{ gridColumn: 'span 2' }}> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<h2>Recent Activity</h2>
<div className="space-y-4 mt-4"> {/* Chart Section */}
{recentLogs.length > 0 ? recentLogs.map((log) => { <div className="glass-panel xl:col-span-2 flex flex-col hover:shadow-lg transition-shadow duration-300 relative overflow-hidden">
const isReceipt = log.movement_type === 'receipt'; <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-500 opacity-50"></div>
const date = new Date(log.created_at); <div className="mb-6">
return ( <h2 className="text-xl font-bold text-slate-800">Stock Movements</h2>
<div key={log.id} className="flex justify-between items-center p-3 bg-white/5 rounded-lg border border-white/5"> <p className="text-sm text-slate-500">Items received vs dispensed over the last 7 days</p>
<div> </div>
<p className="font-medium">{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}</p>
<p className="text-sm text-gray-400"> <div className="flex-1 min-h-[350px] w-full">
{isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no}) {metrics.trend.length > 0 ? (
</p> <ResponsiveContainer width="100%" height="100%">
</div> <AreaChart data={metrics.trend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<span className="text-xs text-gray-400">{date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span> <defs>
</div> <linearGradient id="colorReceived" x1="0" y1="0" x2="0" y2="1">
); <stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
}) : ( <stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
<p className="text-sm text-gray-400">No recent activity.</p> </linearGradient>
<linearGradient id="colorDispensed" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#ec4899" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="date" stroke="#94a3b8" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#94a3b8" fontSize={12} tickLine={false} axisLine={false} dx={-10} />
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" vertical={false} />
<Tooltip
contentStyle={{ backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '12px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', color: '#0f172a' }}
itemStyle={{ fontSize: '14px', fontWeight: 'bold' }}
/>
<Area type="monotone" dataKey="received" name="Received" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorReceived)" />
<Area type="monotone" dataKey="dispensed" name="Dispensed" stroke="#ec4899" strokeWidth={3} fillOpacity={1} fill="url(#colorDispensed)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 bg-slate-50/50 rounded-xl border border-dashed border-slate-200">
<Activity size={48} className="mb-4 opacity-20" />
<p className="font-medium">No trend data available for the past week.</p>
</div>
)} )}
</div> </div>
</div> </div>
<div className="glass-panel"> {/* Recent Activity Section */}
<h2>Quick Actions</h2> <div className="glass-panel flex flex-col hover:shadow-lg transition-shadow duration-300">
<div className="space-y-4 mt-4 flex flex-col"> <div className="flex justify-between items-center mb-6 border-b border-slate-100 pb-4">
<button className="btn btn-primary w-full">New Prescription</button> <h2 className="text-xl font-bold text-slate-800">Recent Activity</h2>
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">Add Inventory</button> <button
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">View Reports</button> className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white text-sm font-semibold rounded-xl transition-all shadow-md shadow-blue-500/20 hover:shadow-blue-500/40 active:scale-95"
onClick={() => window.location.href = '/prescriptions'}
>
+ Quick Rx
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
{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 gap-4 p-3 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50/50 transition-colors group">
<div className={`mt-1 flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${isReceipt ? 'bg-emerald-100 text-emerald-600' : 'bg-pink-100 text-pink-600'}`}>
{isReceipt ? <Package size={16} /> : <Pill size={16} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-800 text-sm truncate group-hover:text-blue-700 transition-colors">
{isReceipt ? 'Stock Replenished' : 'Medication Dispensed'}
</p>
<p className="text-xs text-slate-500 mt-0.5">
{isReceipt ? 'Received' : 'Dispensed'} <span className="font-medium text-slate-700">{Math.abs(log.qty_delta)} units</span> (Batch: {log.batch_no})
</p>
<p className="text-[10px] text-slate-400 mt-1 font-medium">
{date.toLocaleDateString()} • {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
);
}) : (
<div className="py-10 text-center flex flex-col items-center justify-center">
<div className="w-12 h-12 bg-slate-50 rounded-full flex items-center justify-center mb-3">
<Activity size={24} className="text-slate-300" />
</div>
<p className="text-sm font-medium text-slate-500">All caught up!</p>
<p className="text-xs text-slate-400 mt-1">No recent activity found.</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+179 -14
View File
@@ -4,7 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
import { CustomSelect } from '../components/CustomSelect'; import { CustomSelect } from '../components/CustomSelect';
import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine } from '../services/api'; import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine, adjustStock } from '../services/api';
interface InventoryBatch { interface InventoryBatch {
batch_no: string; batch_no: string;
@@ -74,6 +74,19 @@ const columns = [
); );
} }
}), }),
columnHelper.display({
id: 'actions',
header: '',
size: 100,
cell: info => (
<button
onClick={() => window.dispatchEvent(new CustomEvent('openAdjustModal', { detail: info.row.original }))}
className="text-blue-600 hover:text-blue-800 font-bold text-xs bg-blue-50 px-3 py-1 rounded"
>
Adjust
</button>
)
}),
]; ];
const Inventory = () => { const Inventory = () => {
@@ -88,6 +101,7 @@ const Inventory = () => {
const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all'); const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false); const [showFilterMenu, setShowFilterMenu] = useState(false);
const [drugOptions, setDrugOptions] = useState<any[]>([]); const [drugOptions, setDrugOptions] = useState<any[]>([]);
const [supplierOptions, setSupplierOptions] = useState<any[]>([]);
const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock'); const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock');
const [newMedicine, setNewMedicine] = useState({ const [newMedicine, setNewMedicine] = useState({
@@ -96,7 +110,9 @@ const Inventory = () => {
manufacturer: '', manufacturer: '',
form: 'tablet', form: 'tablet',
strength: '', strength: '',
schedule: 'H' schedule: 'H',
mrp: '',
pack_size: '1'
}); });
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -109,6 +125,32 @@ const Inventory = () => {
purchase_price: '' purchase_price: ''
}); });
const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false);
const [selectedBatch, setSelectedBatch] = useState<InventoryBatch | null>(null);
const [adjustForm, setAdjustForm] = useState({ qty_delta: '', reason_code: 'damaged', notes: '' });
const [isAdjusting, setIsAdjusting] = useState(false);
const submitAdjustStock = async () => {
if (!selectedBatch || !adjustForm.qty_delta) return;
try {
setIsAdjusting(true);
await adjustStock({
batch_no: selectedBatch.batch_no,
qty_delta: parseInt(adjustForm.qty_delta),
reason_code: adjustForm.reason_code,
notes: adjustForm.notes
});
await loadData();
setIsAdjustModalOpen(false);
alert("Stock adjusted successfully!");
} catch (err) {
console.error(err);
alert("Failed to adjust stock.");
} finally {
setIsAdjusting(false);
}
};
const submitStock = async () => { const submitStock = async () => {
try { try {
setIsAddingStock(true); setIsAddingStock(true);
@@ -123,7 +165,9 @@ const Inventory = () => {
manufacturer: newMedicine.manufacturer, manufacturer: newMedicine.manufacturer,
form: newMedicine.form, form: newMedicine.form,
strength: newMedicine.strength, strength: newMedicine.strength,
schedule: newMedicine.schedule schedule: newMedicine.schedule,
mrp: parseFloat(newMedicine.mrp) || 0,
pack_size: parseInt(newMedicine.pack_size, 10) || 1
}; };
const medRes = await addMedicine(medPayload); const medRes = await addMedicine(medPayload);
if (medRes && medRes.product_id) { if (medRes && medRes.product_id) {
@@ -155,7 +199,7 @@ const Inventory = () => {
// Reset state // Reset state
setStockMode('restock'); setStockMode('restock');
setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H' }); setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H', mrp: '', pack_size: '1' });
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' }); setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
alert("Stock added successfully! Inventory count increased."); alert("Stock added successfully! Inventory count increased.");
@@ -198,6 +242,16 @@ const Inventory = () => {
if (drugsData && drugsData.drugs) { if (drugsData && drugsData.drugs) {
setDrugOptions(drugsData.drugs); setDrugOptions(drugsData.drugs);
} }
import('../services/api').then(async (api) => {
try {
const supps = await api.getSuppliers();
if (supps && supps.suppliers) {
setSupplierOptions(supps.suppliers);
}
} catch(e) { console.error("Could not fetch suppliers"); }
});
} catch (err: any) { } catch (err: any) {
if (err.response && err.response.status === 401) { if (err.response && err.response.status === 401) {
try { try {
@@ -229,8 +283,19 @@ const Inventory = () => {
setFilterMode(e.detail); setFilterMode(e.detail);
} }
}; };
const handleOpenAdjust = (e: any) => {
if (e.detail) {
setSelectedBatch(e.detail);
setAdjustForm({ qty_delta: '', reason_code: 'damaged', notes: '' });
setIsAdjustModalOpen(true);
}
};
window.addEventListener('setFilter', handleSetFilter); window.addEventListener('setFilter', handleSetFilter);
return () => window.removeEventListener('setFilter', handleSetFilter); window.addEventListener('openAdjustModal', handleOpenAdjust);
return () => {
window.removeEventListener('setFilter', handleSetFilter);
window.removeEventListener('openAdjustModal', handleOpenAdjust);
};
}, []); }, []);
// Filter based on search query // Filter based on search query
@@ -459,6 +524,14 @@ const Inventory = () => {
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Strength</label> <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" /> <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>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">MRP (Price)</label>
<input type="number" step="0.01" value={newMedicine.mrp} onChange={e => setNewMedicine({...newMedicine, 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 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. 15.50" />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Pack Size</label>
<input type="number" value={newMedicine.pack_size} onChange={e => setNewMedicine({...newMedicine, pack_size: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 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. 10" />
</div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Form</label> <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' }}> <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' }}>
@@ -482,18 +555,22 @@ const Inventory = () => {
</div> </div>
</> </>
)} )}
{/* GRN Details */} {/* GRN Details */}
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Supplier ID</label> <div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
<input <label className="block text-xs font-bold uppercase tracking-wider text-slate-500">Supplier</label>
type="text" </div>
<select
value={formData.supplier_id} value={formData.supplier_id}
onChange={(e) => setFormData({ ...formData, supplier_id: e.target.value })} 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"
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' }} style={{ padding: '12px 16px' }}
/> >
<option value="">-- Select Supplier (Optional) --</option>
{supplierOptions.map(sup => (
<option key={sup.id} value={sup.id}>{sup.name} {sup.contact_number ? `(${sup.contact_number})` : ''}</option>
))}
</select>
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Invoice No</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Invoice No</label>
@@ -541,7 +618,7 @@ const Inventory = () => {
</div> </div>
{/* Quantity */} {/* Quantity */}
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Quantity</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Quantity (Strips/Boxes)</label>
<div className="relative group"> <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"> <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} /> <Hash size={18} strokeWidth={2} />
@@ -550,7 +627,7 @@ const Inventory = () => {
type="number" type="number"
value={formData.qty} value={formData.qty}
onChange={(e) => setFormData({ ...formData, qty: e.target.value })} onChange={(e) => setFormData({ ...formData, qty: e.target.value })}
placeholder="Enter quantity" placeholder="Enter quantity (e.g. 5 strips)"
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" 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' }} style={{ padding: '12px 16px', paddingLeft: '44px' }}
/> />
@@ -604,6 +681,94 @@ const Inventory = () => {
</div> </div>
</div> </div>
)} )}
{/* Adjust Stock Modal */}
{isAdjustModalOpen && selectedBatch && (
<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-lg 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">Adjust Stock</h3>
<button
onClick={() => setIsAdjustModalOpen(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="overflow-y-auto flex flex-col gap-4" style={{ padding: '24px 32px' }}>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<p className="text-sm text-slate-500 font-bold uppercase tracking-wider mb-1">Batch Info</p>
<p className="text-lg font-bold text-slate-800">{selectedBatch.brand_name}</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm font-medium text-slate-600">Batch: {selectedBatch.batch_no}</span>
<span className="text-sm font-medium text-slate-600">Current Qty: {selectedBatch.qty_on_hand}</span>
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Quantity Adjustment</label>
<input
type="number"
value={adjustForm.qty_delta}
onChange={(e) => setAdjustForm({ ...adjustForm, qty_delta: e.target.value })}
placeholder="e.g. -5 (for write-off), 10 (for found stock)"
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 mb-2">Reason</label>
<select
value={adjustForm.reason_code}
onChange={(e) => setAdjustForm({ ...adjustForm, reason_code: 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="damaged">Damaged / Broken</option>
<option value="expired">Expired on Shelf</option>
<option value="lost">Lost / Missing</option>
<option value="correction">Stock Correction (Audit)</option>
<option value="return">Patient Return</option>
</select>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Additional Notes</label>
<textarea
value={adjustForm.notes}
onChange={(e) => setAdjustForm({ ...adjustForm, notes: e.target.value })}
rows={2}
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="Optional notes..."
></textarea>
</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={() => setIsAdjustModalOpen(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={submitAdjustStock}
disabled={isAdjusting || !adjustForm.qty_delta}
className={`shrink-0 text-[15px] font-bold text-white rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2 ${isAdjusting || !adjustForm.qty_delta ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
}`}
style={{ padding: '12px 32px' }}
>
{isAdjusting ? 'Saving...' : 'Confirm Adjustment'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+122
View File
@@ -0,0 +1,122 @@
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100vw;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.login-box {
background: white;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h2 {
color: #2c3e50;
margin-bottom: 0.5rem;
font-size: 1.8rem;
}
.login-header p {
color: #7f8c8d;
font-size: 0.95rem;
}
.login-error {
background-color: #fde8e8;
color: #e74c3c;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.875rem;
text-align: center;
border: 1px solid #fadbd8;
}
.login-form .form-group {
margin-bottom: 1.5rem;
}
.login-form label {
display: block;
margin-bottom: 0.5rem;
color: #34495e;
font-size: 0.9rem;
font-weight: 500;
}
.login-form input {
width: 100%;
padding: 0.75rem;
border: 1px solid #bdc3c7;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.login-form input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.login-button {
width: 100%;
padding: 0.875rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
margin-top: 1rem;
}
.login-button:hover {
background-color: #2980b9;
}
.login-button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input-wrapper input {
padding-right: 2.5rem;
}
.password-toggle-btn {
position: absolute;
right: 0.75rem;
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.password-toggle-btn:hover {
color: #34495e;
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { loginPharmacist } from '../services/api';
import './Login.css';
const Login = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await loginPharmacist({ username, password });
login();
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to login. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-box">
<div className="login-header">
<h2>AI Pharmacy</h2>
<p>Sign in to your account</p>
</div>
{error && <div className="login-error">{error}</div>}
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<div className="password-input-wrapper">
<input
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
<button
type="button"
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<button type="submit" className="login-button" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
};
export default Login;
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useState, useMemo } from 'react';
import { fetchSyncedPatients } from '../services/api';
import { UserCircle, Mail, Phone, CalendarClock, ShieldCheck, Search } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
const columnHelper = createColumnHelper<any>();
const columns = [
columnHelper.accessor('full_name', {
header: 'Patient',
size: 350,
cell: info => {
const patient = info.row.original;
return (
<div className="py-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-100/50 rounded-full flex items-center justify-center text-emerald-600 transition-colors duration-300">
<UserCircle className="w-5 h-5" />
</div>
<div>
<div className="font-semibold text-slate-800 text-[14px]">{patient.full_name}</div>
<div className="text-[12px] text-slate-400 font-mono mt-0.5">ID: {patient.hospital_patient_id}</div>
</div>
</div>
</div>
);
}
}),
columnHelper.accessor('contact_number', {
header: 'Contact',
size: 200,
cell: info => (
<div className="flex items-center gap-2 text-[14px] text-slate-600">
<Phone className="w-4 h-4 text-emerald-500" />
{info.getValue() || 'N/A'}
</div>
)
}),
columnHelper.accessor('email', {
header: 'Email',
size: 250,
cell: info => (
<div className="flex items-center gap-2 text-[14px] text-slate-600">
<Mail className="w-4 h-4 text-emerald-500" />
{info.getValue() || 'N/A'}
</div>
)
}),
columnHelper.accessor('created_at', {
header: 'Synced At',
size: 200,
cell: info => (
<div className="flex items-center gap-2 text-[14px] text-slate-600">
<CalendarClock className="w-4 h-4 text-emerald-500" />
{new Date(info.getValue()).toLocaleString()}
</div>
)
})
];
export default function Patients() {
const [patients, setPatients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchSyncedPatients()
.then(data => {
setPatients(data);
setLoading(false);
})
.catch(err => {
console.error('Error fetching synced patients:', err);
setLoading(false);
});
}, []);
const filteredPatients = useMemo(() => {
const safePatients = Array.isArray(patients) ? patients : [];
if (!searchQuery.trim()) return safePatients;
const lowerQuery = searchQuery.toLowerCase();
return safePatients.filter(patient =>
patient && (
(patient.full_name && String(patient.full_name).toLowerCase().includes(lowerQuery)) ||
(patient.hospital_patient_id && String(patient.hospital_patient_id).toLowerCase().includes(lowerQuery)) ||
(patient.contact_number && String(patient.contact_number).toLowerCase().includes(lowerQuery)) ||
(patient.email && String(patient.email).toLowerCase().includes(lowerQuery))
)
);
}, [patients, searchQuery]);
if (loading) {
return (
<div className="flex items-center justify-center h-full min-h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-emerald-600 rounded-full animate-spin"></div>
</div>
);
}
const totalItems = filteredPatients.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredPatients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full p-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col md:flex-row justify-between items-center bg-white/50 backdrop-blur-xl p-6 rounded-2xl border border-white/20 shadow-sm mb-6 gap-4">
<div className="w-full md:w-auto">
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-500 bg-clip-text text-transparent">
Hospital Patient Directory
</h1>
<p className="text-gray-500 mt-1 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
Securely Synced via AI Integration
</p>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-80 group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-emerald-500" style={{ paddingLeft: '16px' }}>
<Search size={18} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1); // Reset page on search
}}
placeholder="Search by name, ID or contact..."
className="w-full py-2.5 bg-white border border-slate-200 hover:border-slate-300 rounded-xl focus:outline-none focus:ring-4 focus:ring-emerald-500/10 focus:border-emerald-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800 text-[14px]"
style={{ paddingLeft: '44px', paddingRight: '16px' }}
/>
</div>
<div className="text-sm font-medium px-4 py-2 bg-emerald-50 text-emerald-700 rounded-full border border-emerald-100 whitespace-nowrap">
{filteredPatients.length} Records
</div>
</div>
</div>
<div className="w-full flex flex-col bg-white border border-slate-200 rounded-2xl shadow-sm relative">
<div className="overflow-x-auto">
{(!Array.isArray(patients) || patients.length === 0) ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<UserCircle className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Patients Found</h3>
<p className="max-w-md mx-auto">No patients have been synced from the hospital yet.</p>
</div>
) : filteredPatients.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Search className="w-12 h-12 mx-auto mb-4 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Matches Found</h3>
<p className="max-w-md mx-auto">No patients match your search "{searchQuery}".</p>
</div>
) : (
<Table
data={currentData}
columns={columns}
getRowId={(item) => item.id}
emptyMessage="No patients found"
/>
)}
</div>
{totalItems > 0 && (
<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>
);
}
+41 -35
View File
@@ -55,8 +55,8 @@ const columns = [
} }
}), }),
columnHelper.accessor('price', { columnHelper.accessor('price', {
header: 'Price', header: 'Purchase Price / MRP',
size: 120, size: 150,
cell: info => <span className="text-slate-800 font-medium text-[14px]">{info.getValue()}</span> cell: info => <span className="text-slate-800 font-medium text-[14px]">{info.getValue()}</span>
}), }),
columnHelper.accessor('manufacture', { columnHelper.accessor('manufacture', {
@@ -89,6 +89,7 @@ const Pharmacy = () => {
form: 'tablet', form: 'tablet',
strength: '', strength: '',
mrp: '', mrp: '',
pack_size: '1',
schedule: 'H' schedule: 'H'
}); });
@@ -160,7 +161,7 @@ const Pharmacy = () => {
setIsAdding(true); setIsAdding(true);
await addMedicine(addFormData); await addMedicine(addFormData);
setIsAddModalOpen(false); setIsAddModalOpen(false);
setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', schedule: 'H' }); setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', pack_size: '1', schedule: 'H' });
await loadData(); await loadData();
} catch (err) { } catch (err) {
console.error("Failed to add medicine", err); console.error("Failed to add medicine", err);
@@ -177,8 +178,8 @@ const Pharmacy = () => {
subtitle: drug.generic_name || '', subtitle: drug.generic_name || '',
badge: drug.schedule || '', badge: drug.schedule || '',
stock: parseInt(drug.total_stock) || 0, stock: parseInt(drug.total_stock) || 0,
rawPrice: parseFloat(drug.mrp) || 0, rawPrice: parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0,
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.mrp) || 0), price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0),
manufacture: drug.manufacturer || 'Unknown', manufacture: drug.manufacturer || 'Unknown',
type: drug.form || 'Unknown' type: drug.form || 'Unknown'
})); }));
@@ -250,39 +251,48 @@ const Pharmacy = () => {
return ( return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full"> <div className="max-w-[1400px] mx-auto flex flex-col w-full">
<header className="mb-6 shrink-0 flex items-center justify-between"> <div className="flex flex-col mb-8 gap-2">
<div> <div className="flex items-center gap-4">
<h1>Pharmacy Overview</h1> <div className="bg-blue-100 p-3 rounded-xl flex items-center justify-center">
<Package className="text-blue-600" size={26} />
</div>
<h1 className="text-3xl font-bold text-slate-900 m-0 leading-none">Pharmacy Overview</h1>
</div> </div>
</header> <p className="text-slate-500 mt-2">Manage your pharmacy inventory, add medicines, and handle manual dispensing.</p>
</div>
{/* Metrics Row */} {/* Metrics Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0" style={{ marginBottom: '32px' }}> <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="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center shrink-0"> <div className="p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200/50 flex items-center justify-center shrink-0 shadow-sm">
<Package className="text-blue-600" size={24} /> <Package className="text-blue-600" size={28} />
</div> </div>
<div> <div className="flex flex-col">
<p className="text-slate-500 text-[13px] font-medium mb-1">Total Medicines</p> <p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Total Medicines</p>
<h3 className="text-2xl font-bold text-slate-900">{inventory.length}</h3> <h3 className="text-3xl font-extrabold text-slate-900">{inventory.length}</h3>
</div> </div>
</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"> <div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5 relative overflow-hidden">
<AlertCircle className="text-red-500" size={24} /> {lowStockCount > 0 && (
<div className="absolute top-0 right-0 w-2 h-full bg-red-500"></div>
)}
<div className="p-4 rounded-2xl bg-gradient-to-br from-red-50 to-red-100 border border-red-200/50 flex items-center justify-center shrink-0 shadow-sm">
<AlertCircle className="text-red-600" size={28} />
</div> </div>
<div> <div className="flex flex-col">
<p className="text-slate-500 text-[13px] font-medium mb-1">Low Stock Alerts</p> <p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Low Stock Alerts</p>
<h3 className="text-2xl font-bold text-slate-900">{lowStockCount}</h3> <h3 className={`text-3xl font-extrabold ${lowStockCount > 0 ? 'text-red-600' : 'text-slate-900'}`}>{lowStockCount}</h3>
</div> </div>
</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"> <div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5">
<Layers className="text-indigo-500" size={24} /> <div className="p-4 rounded-2xl bg-gradient-to-br from-indigo-50 to-indigo-100 border border-indigo-200/50 flex items-center justify-center shrink-0 shadow-sm">
<Layers className="text-indigo-600" size={28} />
</div> </div>
<div> <div className="flex flex-col">
<p className="text-slate-500 text-[13px] font-medium mb-1">Unique Categories</p> <p className="text-slate-500 text-sm font-semibold tracking-wide uppercase mb-1">Unique Categories</p>
<h3 className="text-2xl font-bold text-slate-900">{uniqueCategories}</h3> <h3 className="text-3xl font-extrabold text-slate-900">{uniqueCategories}</h3>
</div> </div>
</div> </div>
</div> </div>
@@ -311,14 +321,6 @@ const Pharmacy = () => {
<Package size={18} strokeWidth={2.5} /> <Package size={18} strokeWidth={2.5} />
Manual Dispense Manual Dispense
</button> </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> </div>
@@ -422,6 +424,10 @@ const Pharmacy = () => {
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>MRP (₹)</label> <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' }} /> <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>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Pack Size</label>
<input type="number" value={addFormData.pack_size} onChange={e => setAddFormData({...addFormData, pack_size: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="1" style={{ padding: '12px 16px' }} />
</div>
</div> </div>
<div className="flex items-center justify-end border-t border-slate-100" style={{ paddingTop: '16px', gap: '12px' }}> <div className="flex items-center justify-end border-t border-slate-100" style={{ paddingTop: '16px', gap: '12px' }}>
+152 -70
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye } from 'lucide-react'; import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye, User, Stethoscope, CalendarDays, Activity } from 'lucide-react';
import { uploadPrescription, getPrescriptionDetails, confirmPrescription, precheckOrder, dispenseOrder, getAllPrescriptions, getDrugs } from '../services/api'; import { uploadPrescription, getPrescriptionDetails, confirmPrescription, precheckOrder, dispenseOrder, getAllPrescriptions, getDrugs } from '../services/api';
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
@@ -26,6 +26,7 @@ const Prescriptions = () => {
const [dispenseSuccess, setDispenseSuccess] = useState(false); const [dispenseSuccess, setDispenseSuccess] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('cash'); const [paymentMethod, setPaymentMethod] = useState('cash');
const [drugOptions, setDrugOptions] = useState<any[]>([]); const [drugOptions, setDrugOptions] = useState<any[]>([]);
const [mappedItems, setMappedItems] = useState<Record<number, string>>({});
// Queue State // Queue State
const [prescriptionsList, setPrescriptionsList] = useState<any[]>([]); const [prescriptionsList, setPrescriptionsList] = useState<any[]>([]);
@@ -39,11 +40,39 @@ const Prescriptions = () => {
}).catch(console.error); }).catch(console.error);
}, []); }, []);
useEffect(() => {
if (prescriptionData && drugOptions.length > 0) {
const items = prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || [];
const newMapping: Record<number, string> = {};
items.forEach((m: any, idx: number) => {
const name = m.parsed?.brand || m.name || m.verbatim || '';
if (!name) return;
let matchedDrug = drugOptions.find(d =>
name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
d.brand_name.toLowerCase().includes(name.toLowerCase()) ||
(d.generic_name && (name.toLowerCase().includes(d.generic_name.toLowerCase()) || d.generic_name.toLowerCase().includes(name.toLowerCase())))
);
if (matchedDrug) {
newMapping[idx] = matchedDrug.product_id;
}
});
setMappedItems(newMapping);
}
}, [prescriptionData, drugOptions]);
const fetchPrescriptions = async () => { const fetchPrescriptions = async () => {
try { try {
const data = await getAllPrescriptions(); const data = await getAllPrescriptions();
if (data && data.prescriptions) { if (data && data.prescriptions) {
setPrescriptionsList(data.prescriptions); // Tag digital ones based on the image_path inserted by the webhook
const tagged = data.prescriptions.map((p: any) => ({
...p,
isDigital: p.image_path === 'E-Prescription (Digital Webhook)'
}));
setPrescriptionsList(tagged);
} }
} catch (err) { } catch (err) {
console.error("Failed to load queue", err); console.error("Failed to load queue", err);
@@ -131,14 +160,22 @@ const Prescriptions = () => {
size: 150, size: 150,
cell: info => { cell: info => {
const status = info.getValue(); const status = info.getValue();
const isDigital = info.row.original.isDigital;
return ( return (
<span className={`px-2.5 py-1 rounded-md text-[11px] font-bold tracking-wide uppercase ${ <div className="flex gap-2 items-center">
status === 'confirmed' ? 'bg-emerald-100 text-emerald-700 border border-emerald-200' : {isDigital && (
status === 'processing' ? 'bg-amber-100 text-amber-700 border border-amber-200' : <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 border border-indigo-200" title="Digital E-Prescription">
'bg-blue-100 text-blue-700 border border-blue-200' E-RX
}`}> </span>
{status} )}
</span> <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>
</div>
); );
} }
}), }),
@@ -214,18 +251,13 @@ const Prescriptions = () => {
allergies: [], allergies: [],
conditions: [] conditions: []
}, },
items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => { items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => {
const name = m.parsed?.brand || m.name || m.verbatim || ''; let productId = mappedItems[idx];
if (!productId) {
throw new Error(`Product mapping missing for ${m.parsed?.brand || m.name || m.verbatim}`);
}
let matchedDrug = drugOptions.find(d => let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity);
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)) { if (!qty || isNaN(qty)) {
const doseStr = m.parsed?.dose || m.dosage || ''; const doseStr = m.parsed?.dose || m.dosage || '';
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/); const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
@@ -263,18 +295,13 @@ const Prescriptions = () => {
const payload = { const payload = {
subject_ref: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown', subject_ref: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown',
prescriber_name: prescriptionData.extracted_data.prescriber?.name || 'Unknown Prescriber', prescriber_name: prescriptionData.extracted_data.prescriber?.name || 'Unknown Prescriber',
items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => { items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => {
const name = m.parsed?.brand || m.name || m.verbatim || ''; let productId = mappedItems[idx];
if (!productId) {
throw new Error(`Product mapping missing for ${m.parsed?.brand || m.name || m.verbatim}`);
}
let matchedDrug = drugOptions.find(d => let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity);
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)) { if (!qty || isNaN(qty)) {
// Try to extract qty from dosage like 1-0-1 // Try to extract qty from dosage like 1-0-1
const doseStr = m.parsed?.dose || m.dosage || ''; const doseStr = m.parsed?.dose || m.dosage || '';
@@ -406,52 +433,107 @@ const Prescriptions = () => {
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="flex flex-col gap-6 w-full">
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Patient Name</label> <div className="flex flex-col gap-2">
<input <label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
type="text" <User size={16} className="text-slate-400" /> Patient Name
value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''} </label>
onChange={(e) => setPrescriptionData({ <input
...prescriptionData, type="text"
extracted_data: { value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''}
...prescriptionData.extracted_data, onChange={(e) => setPrescriptionData({
patient: { ...prescriptionData.extracted_data?.patient, name: e.target.value }, ...prescriptionData,
patient_name: e.target.value extracted_data: {
} ...prescriptionData.extracted_data,
})} patient: { ...prescriptionData.extracted_data?.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" patient_name: e.target.value
/> }
})}
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
/>
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<Stethoscope size={16} className="text-slate-400" /> Prescribing 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-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
/>
</div>
</div> </div>
<div> <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Doctor</label> <div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
<input <Pill className="text-blue-500" size={20} />
type="text" <h3 className="font-semibold text-slate-800 text-lg">Prescribed Medications</h3>
value={prescriptionData.extracted_data?.prescriber?.name || prescriptionData.extracted_data?.doctor_name || ''} <span className="ml-auto bg-slate-100 text-slate-600 py-1 px-3 rounded-full text-xs font-semibold">
onChange={(e) => setPrescriptionData({ {(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).length} Items
...prescriptionData, </span>
extracted_data: { </div>
...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> <div className="flex flex-col gap-4 w-full">
<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) => ( {(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"> <div key={idx} className="p-5 rounded-lg border border-slate-200 bg-slate-50/50 hover:bg-slate-50 transition-colors">
<span className="font-bold text-slate-800">{med.parsed?.brand || med.name || med.verbatim}</span> <div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-4">
<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>
<h4 className="font-bold text-slate-900 text-lg mb-1">{med.parsed?.brand || med.name || med.verbatim}</h4>
<div className="flex items-center gap-4 text-sm text-slate-600">
{med.parsed?.dose || med.dosage ? (
<span className="font-medium px-2.5 py-0.5 bg-blue-50 text-blue-700 rounded border border-blue-100">{med.parsed?.dose || med.dosage}</span>
) : null}
{med.parsed?.total_qty_str && (
<span className="flex items-center gap-1.5"><Activity size={14} /> Qty: {med.parsed.total_qty_str}</span>
)}
{med.parsed?.duration && (
<span className="flex items-center gap-1.5"><CalendarDays size={14} /> {med.parsed.duration}</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">Map to Inventory Product</label>
<select
value={mappedItems[idx] || ''}
onChange={(e) => setMappedItems({...mappedItems, [idx]: e.target.value})}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-md text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">-- Select Product --</option>
{drugOptions.map(d => (
<option key={d.product_id} value={d.product_id}>{d.brand_name} ({d.generic_name})</option>
))}
</select>
</div>
{med.parsed?.instruction && (
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">Instructions</label>
<div className="px-3 py-2 bg-amber-50/50 border border-amber-100 rounded-md text-sm text-amber-800 italic">
{med.parsed.instruction}
</div>
</div>
)}
</div>
</div> </div>
))} ))}
{!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && ( {!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && (
<p className="text-sm text-slate-500 italic">No medicines detected.</p> <div className="text-center p-8 border border-dashed border-slate-300 rounded-lg">
<Pill size={24} className="mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-500 font-medium">No medicines detected.</p>
</div>
)} )}
</div> </div>
</div> </div>
+63
View File
@@ -0,0 +1,63 @@
import { User, Shield, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
const Settings = () => {
const navigate = useNavigate();
const settingsCards = [
{
id: 'profile',
title: 'Edit Profile',
description: 'Manage your pharmacy profile, licensing details, and personal information.',
icon: <User size={24} />,
color: 'blue',
path: '/settings/profile'
},
{
id: 'security',
title: 'Change Password',
description: 'Update your account password and manage security preferences.',
icon: <Shield size={24} />,
color: 'emerald',
path: '/settings/security'
}
];
return (
<div className="max-w-4xl mx-auto flex flex-col w-full">
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
<p className="text-slate-500 mt-1">Manage your account and application preferences</p>
</div>
</header>
<div className="flex flex-col gap-4">
{settingsCards.map((card) => (
<div
key={card.id}
onClick={() => navigate(card.path)}
className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden hover:shadow-md hover:border-blue-300 transition-all cursor-pointer group"
>
<div className="p-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${card.color === 'blue' ? 'bg-blue-50 text-blue-600 group-hover:bg-blue-100 group-hover:text-blue-700' : 'bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100 group-hover:text-emerald-700'} transition-colors`}>
{card.icon}
</div>
<div>
<h2 className="text-lg font-bold text-slate-800 group-hover:text-blue-600 transition-colors">{card.title}</h2>
<p className="text-sm text-slate-500 mt-0.5">{card.description}</p>
</div>
</div>
<div className="text-slate-300 group-hover:text-blue-500 transition-colors">
<ChevronRight size={24} />
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default Settings;
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState, useMemo } from 'react';
import { getAuditLogs, authenticate } from '../services/api';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { createColumnHelper } from '@tanstack/react-table';
import { Activity, ArrowDownRight, ArrowUpRight, Clock, Package, Search } from 'lucide-react';
const columnHelper = createColumnHelper<any>();
const columns = [
columnHelper.accessor('created_at', {
header: 'Date & Time',
size: 200,
cell: info => {
const date = new Date(info.getValue());
return (
<div className="flex flex-col">
<span className="font-semibold text-slate-800">{date.toLocaleDateString()}</span>
<span className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Clock size={12} /> {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
);
}
}),
columnHelper.accessor('movement_type', {
header: 'Movement Type',
size: 200,
cell: info => {
const type = info.getValue();
const isReceipt = type === 'receipt';
return (
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${
isReceipt
? 'bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-pink-100 text-pink-700 border border-pink-200'
}`}>
{isReceipt ? <ArrowDownRight size={14} /> : <ArrowUpRight size={14} />}
{isReceipt ? 'Stock Received' : 'Dispensed'}
</div>
);
}
}),
columnHelper.accessor('batch_no', {
header: 'Batch Number',
size: 200,
cell: info => (
<div className="flex items-center gap-2 font-mono text-sm text-slate-600 bg-slate-50 px-2 py-1 rounded-md border border-slate-100 w-fit">
<Package size={14} className="text-slate-400" />
{info.getValue() || 'N/A'}
</div>
)
}),
columnHelper.accessor('qty_delta', {
header: 'Quantity',
size: 150,
cell: info => {
const qty = info.getValue();
const isPositive = qty > 0;
return (
<span className={`font-bold text-[15px] ${isPositive ? 'text-emerald-600' : 'text-pink-600'}`}>
{isPositive ? '+' : ''}{qty}
</span>
);
}
})
];
export default function StockMovements() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
const fetchData = async () => {
try {
if (!localStorage.getItem('auth_token')) {
await authenticate();
}
const logsData = await getAuditLogs(50); // fetch more logs for the table
if (logsData && logsData.stock_movements) {
setLogs(logsData.stock_movements);
}
} catch (err) {
console.error('Failed to load stock movements:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const filteredLogs = useMemo(() => {
if (!searchQuery.trim()) return logs;
const lowerQuery = searchQuery.toLowerCase();
return logs.filter(log =>
(log.batch_no && log.batch_no.toLowerCase().includes(lowerQuery)) ||
(log.movement_type && log.movement_type.toLowerCase().includes(lowerQuery))
);
}, [logs, searchQuery]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery]);
const totalItems = filteredLogs.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = useMemo(() => {
return filteredLogs.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
}, [filteredLogs, currentPage, itemsPerPage]);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full p-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<header>
<div className="flex items-center gap-3">
<div className="bg-blue-100 p-2.5 rounded-xl">
<Activity className="text-blue-600" size={24} />
</div>
<h1 className="text-3xl font-bold text-slate-900 m-0 leading-none">Stock Movements</h1>
</div>
<p className="text-slate-500 mt-2">
Detailed history of inventory receipts and dispensations.
</p>
</header>
<div className="flex items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-80 group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none text-slate-400" style={{ paddingLeft: '16px' }}>
<Search size={18} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by batch no or type..."
className="w-full py-2.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px' }}
/>
</div>
<div className="text-sm font-bold px-4 py-2.5 bg-slate-100 text-slate-700 rounded-lg whitespace-nowrap border border-slate-200 shadow-sm">
{filteredLogs.length} Records
</div>
</div>
</div>
<div className="w-full flex flex-col bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-blue-600 rounded-full animate-spin"></div>
</div>
) : (
<>
<Table data={currentData} columns={columns} emptyMessage="No stock movements found matching your search." />
{totalItems > 0 && (
<div className="border-t border-slate-100 bg-slate-50/30">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50]}
onPageChange={(page) => setCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setItemsPerPage(limit);
setCurrentPage(1);
}}
/>
</div>
)}
</>
)}
</div>
</div>
);
}
+424
View File
@@ -0,0 +1,424 @@
import { useState, useEffect } from 'react';
import { Search, Plus, AlertTriangle, X, Truck, User, Phone, Mail, FileText, MapPin } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { getSuppliers, addSupplier, authenticate } from '../services/api';
interface Supplier {
id: string;
name: string;
contact_number: string;
email: string;
gstin: string;
dl_number: string;
address: string;
contact_person: string;
created_at: string;
}
const columnHelper = createColumnHelper<Supplier>();
const columns = [
columnHelper.accessor('name', {
header: 'COMPANY NAME',
size: 250,
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('contact_person', {
header: 'CONTACT PERSON',
size: 180,
cell: info => <span className="text-slate-600 font-medium text-[14px]">{info.getValue() || '-'}</span>
}),
columnHelper.accessor('contact_number', {
header: 'PHONE',
size: 150,
cell: info => <span className="text-slate-600 font-medium text-[14px]">{info.getValue() || '-'}</span>
}),
columnHelper.accessor('email', {
header: 'EMAIL',
size: 200,
cell: info => <span className="text-slate-600 font-medium text-[14px]">{info.getValue() || '-'}</span>
}),
columnHelper.accessor('dl_number', {
header: 'DL NUMBER',
size: 180,
cell: info => (
<span className="bg-emerald-50 text-emerald-700 px-3 py-1 rounded font-bold text-[11px] tracking-wide border border-emerald-100">
{info.getValue() || '-'}
</span>
)
}),
columnHelper.accessor('gstin', {
header: 'GSTIN',
size: 180,
cell: info => (
<span className="bg-blue-50 text-blue-700 px-3 py-1 rounded font-bold text-[11px] tracking-wide border border-blue-100">
{info.getValue() || '-'}
</span>
)
}),
columnHelper.accessor('address', {
header: 'ADDRESS',
size: 300,
cell: info => <span className="text-slate-500 text-[13px] truncate max-w-[250px] inline-block" title={info.getValue()}>{info.getValue() || '-'}</span>
})
];
const Suppliers = () => {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddingSupplier, setIsAddingSupplier] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState({
name: '',
contact_person: '',
contact_number: '',
email: '',
gstin: '',
dl_number: '',
address: ''
});
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
if (!localStorage.getItem('auth_token')) {
await authenticate();
}
const data = await getSuppliers();
if (data && data.suppliers) {
setSuppliers(data.suppliers);
}
} catch (err: any) {
if (err.response && err.response.status === 401) {
try {
await authenticate();
const retryData = await getSuppliers();
if (retryData && retryData.suppliers) {
setSuppliers(retryData.suppliers);
}
} catch (retryError) {
console.error("Failed to fetch suppliers after retry:", retryError);
setError("Authentication failed. Please try logging in again.");
}
} else {
console.error("Failed to fetch suppliers:", err);
setError("Failed to load suppliers data.");
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const submitSupplier = async () => {
if (!formData.name) {
alert("Company Name is required!");
return;
}
try {
setIsAddingSupplier(true);
await addSupplier(formData);
await loadData();
setIsModalOpen(false);
setFormData({
name: '',
contact_person: '',
contact_number: '',
email: '',
gstin: '',
dl_number: '',
address: ''
});
alert("Supplier added successfully!");
} catch (err) {
console.error("Failed to add supplier:", err);
alert("Failed to add supplier. Please try again.");
} finally {
setIsAddingSupplier(false);
}
};
const filteredSuppliers = suppliers.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(s.contact_person && s.contact_person.toLowerCase().includes(searchQuery.toLowerCase())) ||
(s.dl_number && s.dl_number.toLowerCase().includes(searchQuery.toLowerCase())) ||
(s.gstin && s.gstin.toLowerCase().includes(searchQuery.toLowerCase()))
);
const totalItems = filteredSuppliers.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredSuppliers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-800">Suppliers & Distributors</h1>
<p className="text-slate-500 mt-2 font-medium">Manage your wholesale medicine suppliers.</p>
</div>
</header>
{/* Action Bar */}
<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 suppliers by name, contact, DL or GSTIN..."
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 SUPPLIER
</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 suppliers...</p>
</div>
) : suppliers.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Truck className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Suppliers Found</h3>
<p className="max-w-md mx-auto">You haven't added any wholesale distributors yet.</p>
<button
onClick={() => setIsModalOpen(true)}
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
Add Your First Supplier
</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 Supplier 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 flex items-center gap-2">
<Truck className="text-blue-600" size={24} />
Register New Supplier
</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="grid grid-cols-2 overflow-y-auto" style={{ padding: '24px', gap: '24px' }}>
<div className="col-span-2">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Company Name *</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">
<Truck size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Sri Ram Pharma Distributors"
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' }}>Contact Person</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">
<User size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.contact_person}
onChange={(e) => setFormData({ ...formData, contact_person: e.target.value })}
placeholder="e.g. Rahul Sharma"
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' }}>Phone Number</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">
<Phone size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.contact_number}
onChange={(e) => setFormData({ ...formData, contact_number: e.target.value })}
placeholder="10-digit number"
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' }}>Email Address</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">
<Mail size={18} strokeWidth={2} />
</div>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="contact@company.com"
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' }}>GSTIN Number</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">
<FileText size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.gstin}
onChange={(e) => setFormData({ ...formData, gstin: e.target.value })}
placeholder="15-digit GST Number"
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 className="col-span-2 border-t border-slate-100 mt-2" style={{ paddingTop: '24px' }}>
<label className="block text-xs font-bold uppercase tracking-wider text-emerald-600" style={{ marginBottom: '8px' }}>Drug License (DL) Number</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-emerald-500 group-focus-within:text-emerald-600 transition-colors z-10">
<FileText size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.dl_number}
onChange={(e) => setFormData({ ...formData, dl_number: e.target.value })}
placeholder="TN-xxx-xxxxx"
className="w-full bg-slate-50 border border-emerald-200/60 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-emerald-500/15 focus:border-emerald-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 className="col-span-2">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Full Address</label>
<div className="relative group">
<div className="absolute top-0 left-0 pl-4 flex pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10" style={{ paddingTop: '14px' }}>
<MapPin size={18} strokeWidth={2} />
</div>
<textarea
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="Complete business address..."
rows={3}
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 resize-none 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="text-[15px] font-bold text-slate-600 hover:text-slate-800 hover:bg-slate-200 bg-white border border-slate-200 shadow-sm rounded-xl transition-all"
style={{ padding: '12px 24px' }}
>
Cancel
</button>
<button
onClick={submitSupplier}
disabled={isAddingSupplier || !formData.name}
className={`text-[15px] font-bold text-white rounded-xl transition-all shadow-sm flex items-center justify-center gap-2 ${isAddingSupplier || !formData.name ? 'bg-blue-400 cursor-not-allowed opacity-70' : 'bg-blue-600 hover:bg-blue-700 hover:shadow-md'}`}
style={{ padding: '12px 32px' }}
>
{isAddingSupplier ? 'Registering...' : 'Register Supplier'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Suppliers;
+244
View File
@@ -0,0 +1,244 @@
import { useState, useEffect } from 'react';
import { getMe, updateMe } from '../../services/api';
import { Save, User, CheckCircle, ArrowLeft, Building2, FileText, Hash, MapPin, Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
const ProfileSettings = () => {
const navigate = useNavigate();
const [profile, setProfile] = useState({
username: '',
role: '',
pharmacy_name: '',
dl_number: '',
gstin: '',
pharmacist_reg_number: '',
address: ''
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
const fetchProfile = async () => {
try {
const data = await getMe();
if (data && data.profile) {
setProfile({
username: data.profile.username || '',
role: data.profile.role || '',
pharmacy_name: data.profile.pharmacy_name || '',
dl_number: data.profile.dl_number || '',
gstin: data.profile.gstin || '',
pharmacist_reg_number: data.profile.pharmacist_reg_number || '',
address: data.profile.address || ''
});
}
} catch (err) {
console.error("Failed to load profile", err);
} finally {
setIsLoading(false);
}
};
fetchProfile();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setProfile(prev => ({ ...prev, [name]: value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
setMessage('');
const data = await updateMe({
pharmacy_name: profile.pharmacy_name,
dl_number: profile.dl_number,
gstin: profile.gstin,
pharmacist_reg_number: profile.pharmacist_reg_number,
address: profile.address
});
setMessage('Profile updated successfully!');
setTimeout(() => setMessage(''), 3000);
} catch (err) {
console.error("Failed to update profile", err);
setMessage('Failed to update profile.');
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<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 profile...</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto flex flex-col w-full">
<header className="mb-6 flex items-center gap-4">
<button
onClick={() => navigate('/settings')}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors text-slate-500 hover:text-slate-700"
>
<ArrowLeft size={24} />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-800">Edit Profile</h1>
<p className="text-slate-500 mt-1">Manage your pharmacy profile and personal information</p>
</div>
</header>
{message && (
<div className={`mb-4 p-4 rounded-xl flex items-center gap-2 ${message.includes('successfully') ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'}`}>
{message.includes('successfully') && <CheckCircle size={20} />}
<span className="font-medium">{message}</span>
</div>
)}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="border-b border-slate-100 flex items-center gap-3 bg-slate-50/50" style={{ padding: '24px' }}>
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
<User size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-slate-800">Personal Information</h2>
<p className="text-sm text-slate-500">Update your pharmacy licensing details below</p>
</div>
</div>
<form onSubmit={handleSave} style={{ padding: '32px' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="col-span-1 md:col-span-2">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Pharmacy Name</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">
<Building2 size={18} strokeWidth={2} />
</div>
<input
type="text"
name="pharmacy_name"
value={profile.pharmacy_name}
onChange={handleChange}
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' }}
placeholder="e.g. Apollo Pharmacy"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Drug License (DL) Number</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">
<FileText size={18} strokeWidth={2} />
</div>
<input
type="text"
name="dl_number"
value={profile.dl_number}
onChange={handleChange}
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' }}
placeholder="e.g. TN/20/20B/12345"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>GSTIN</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"
name="gstin"
value={profile.gstin}
onChange={handleChange}
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' }}
placeholder="e.g. 33AAABC1234D1Z5"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Pharmacist Registration 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">
<FileText size={18} strokeWidth={2} />
</div>
<input
type="text"
name="pharmacist_reg_number"
value={profile.pharmacist_reg_number}
onChange={handleChange}
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' }}
placeholder="e.g. 56789"
/>
</div>
</div>
<div className="col-span-1 md:col-span-2">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Pharmacy Address</label>
<div className="relative group">
<div className="absolute top-4 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
<MapPin size={18} strokeWidth={2} />
</div>
<textarea
name="address"
value={profile.address}
onChange={handleChange}
rows={3}
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 resize-none"
style={{ padding: '12px 16px', paddingLeft: '44px' }}
placeholder="Enter full address"
/>
</div>
</div>
{/* Account Metadata - Read Only */}
<div className="col-span-1 md:col-span-2 mt-2 pt-6 border-t border-slate-100 flex flex-wrap gap-8">
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Username</label>
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-100 rounded-lg text-slate-700 font-medium text-sm">
<User size={14} className="text-slate-500" />
{profile.username || 'N/A'}
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Account Role</label>
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg font-medium text-sm capitalize">
<Shield size={14} />
{profile.role || 'User'}
</div>
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-slate-100 flex justify-end">
<button
type="submit"
disabled={isSaving}
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-bold text-white transition-all shadow-sm hover:shadow ${isSaving ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 hover:-translate-y-0.5'}`}
>
<Save size={18} />
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ProfileSettings;
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Shield, CheckCircle, ArrowLeft, Key } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
// import { updatePassword } from '../../services/api'; // Add this API later
const SecuritySettings = () => {
const navigate = useNavigate();
const [passwords, setPasswords] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setPasswords(prev => ({ ...prev, [name]: value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (passwords.newPassword !== passwords.confirmPassword) {
setMessage({ type: 'error', text: 'New passwords do not match.' });
return;
}
try {
setIsSaving(true);
setMessage({ type: '', text: '' });
// const data = await updatePassword({
// current_password: passwords.currentPassword,
// new_password: passwords.newPassword
// });
// Simulating API call
await new Promise(resolve => setTimeout(resolve, 1000));
setMessage({ type: 'success', text: 'Password changed successfully!' });
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
} catch (err) {
console.error("Failed to change password", err);
setMessage({ type: 'error', text: 'Failed to change password. Please check your current password.' });
} finally {
setIsSaving(false);
}
};
return (
<div className="max-w-4xl mx-auto flex flex-col w-full">
<header className="mb-6 flex items-center gap-4">
<button
onClick={() => navigate('/settings')}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors text-slate-500 hover:text-slate-700"
>
<ArrowLeft size={24} />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-800">Security & Password</h1>
<p className="text-slate-500 mt-1">Manage your account security and password</p>
</div>
</header>
{message.text && (
<div className={`mb-4 p-4 rounded-xl flex items-center gap-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'}`}>
{message.type === 'success' && <CheckCircle size={20} />}
<span className="font-medium">{message.text}</span>
</div>
)}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 flex items-center gap-3">
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
<Shield size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-slate-800">Change Password</h2>
<p className="text-sm text-slate-500">Ensure your account is using a strong, unique password</p>
</div>
</div>
<form onSubmit={handleSave} className="p-6">
<div className="max-w-md space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Current Password</label>
<input
type="password"
name="currentPassword"
value={passwords.currentPassword}
onChange={handleChange}
required
className="w-full bg-white border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-shadow"
placeholder="Enter current password"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">New Password</label>
<input
type="password"
name="newPassword"
value={passwords.newPassword}
onChange={handleChange}
required
minLength={8}
className="w-full bg-white border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-shadow"
placeholder="Enter new password"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Confirm New Password</label>
<input
type="password"
name="confirmPassword"
value={passwords.confirmPassword}
onChange={handleChange}
required
minLength={8}
className="w-full bg-white border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-shadow"
placeholder="Confirm new password"
/>
</div>
</div>
<div className="mt-8 pt-6 border-t border-slate-100 flex justify-end">
<button
type="submit"
disabled={isSaving}
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-white transition-all shadow-sm ${isSaving ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'}`}
>
<Key size={18} />
{isSaving ? 'Updating...' : 'Update Password'}
</button>
</div>
</form>
</div>
</div>
);
};
export default SecuritySettings;
+86
View File
@@ -30,6 +30,48 @@ export const authenticate = async () => {
} }
}; };
// Login with username and password
export const loginPharmacist = async (credentials: any) => {
try {
const response = await axios.post('http://localhost:8080/v1/auth/login', credentials);
const data = response.data;
if (data.token) {
localStorage.setItem('auth_token', data.token);
if (data.user && data.user.id) {
localStorage.setItem('actor_id', data.user.id);
localStorage.setItem('user', JSON.stringify(data.user));
}
}
return data;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
// Logout pharmacist
export const logoutPharmacist = async () => {
try {
// Call the logout endpoint
const response = await axios.post('http://localhost:8080/v1/auth/logout');
return response.data;
} catch (error) {
console.error('Logout API failed:', error);
throw error;
}
};
export const getMe = async () => {
const response = await api.get('/auth/me');
return response.data;
};
export const updateMe = async (data: any) => {
const response = await api.put('/auth/me', data);
return response.data;
};
// Request interceptor to automatically add the auth token to headers // Request interceptor to automatically add the auth token to headers
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
@@ -39,6 +81,19 @@ api.interceptors.request.use((config) => {
if (config.headers) { if (config.headers) {
config.headers['X-Actor-Id'] = localStorage.getItem('actor_id') || 'act_admin'; config.headers['X-Actor-Id'] = localStorage.getItem('actor_id') || 'act_admin';
// Extract tenant_id from user object to ensure strict multi-tenancy
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
if (user.tenant_id) {
config.headers['x-tenant-id'] = String(user.tenant_id);
} else if (user.hospital_id) {
config.headers['x-tenant-id'] = String(user.hospital_id);
}
}
} catch (e) {}
} }
return config; return config;
@@ -80,11 +135,36 @@ export const addStock = async (payload: any) => {
}); });
return response.data; return response.data;
}; };
export const adjustStock = async (payload: { batch_no: string, qty_delta: number, reason_code: string, notes?: string }) => {
const idempotencyKey = `idemp-adj-${Date.now()}-${Math.random().toString(36).substring(7)}`;
const response = await api.post('/inventory/adjustments', payload, {
headers: {
'Idempotency-Key': idempotencyKey
}
});
return response.data;
};
export const getSuppliers = async () => {
const response = await api.get('/inventory/suppliers');
return response.data;
};
export const addSupplier = async (data: any) => {
const response = await api.post('/inventory/suppliers', data);
return response.data;
};
export const getAuditLogs = async (limit: number = 10) => { export const getAuditLogs = async (limit: number = 10) => {
const response = await api.get(`/audit/ai-calls?limit=${limit}`); const response = await api.get(`/audit/ai-calls?limit=${limit}`);
return response.data; return response.data;
}; };
export const getDashboardMetrics = async () => {
const response = await api.get('/audit/dashboard-metrics');
return response.data;
};
// --- AI Voice APIs --- // --- AI Voice APIs ---
export const sendVoiceCommand = async (transcript: string) => { export const sendVoiceCommand = async (transcript: string) => {
const response = await api.post('/ai/voice', { transcript }); const response = await api.post('/ai/voice', { transcript });
@@ -168,4 +248,10 @@ export const getH1Register = async () => {
return response.data; return response.data;
}; };
// --- Integration APIs ---
export const fetchSyncedPatients = async () => {
const response = await api.get('/integration/hospital/patients');
return response.data.data;
};
export default api; export default api;