From 8c25b846160833db1aad0fa2c3f630e90cf62c70 Mon Sep 17 00:00:00 2001 From: KavyaCSH Date: Mon, 3 Aug 2026 10:29:58 +0530 Subject: [PATCH] feat: implement core pharmacy management dashboard and navigation infrastructure --- package.json | 4 +- src/App.tsx | 61 +++- src/components/Sidebar.tsx | 53 ++- src/components/VoiceAssistant.tsx | 10 + src/contexts/AuthContext.tsx | 40 +++ src/pages/Dashboard.tsx | 166 +++++++--- src/pages/Inventory.tsx | 193 ++++++++++- src/pages/Login.css | 122 +++++++ src/pages/Login.tsx | 88 +++++ src/pages/Patients.tsx | 188 +++++++++++ src/pages/Pharmacy.tsx | 76 +++-- src/pages/Prescriptions.tsx | 224 +++++++++---- src/pages/Settings.tsx | 63 ++++ src/pages/StockMovements.tsx | 180 ++++++++++ src/pages/Suppliers.tsx | 424 ++++++++++++++++++++++++ src/pages/settings/ProfileSettings.tsx | 244 ++++++++++++++ src/pages/settings/SecuritySettings.tsx | 146 ++++++++ src/services/api.ts | 86 +++++ 18 files changed, 2184 insertions(+), 184 deletions(-) create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/pages/Login.css create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Patients.tsx create mode 100644 src/pages/Settings.tsx create mode 100644 src/pages/StockMovements.tsx create mode 100644 src/pages/Suppliers.tsx create mode 100644 src/pages/settings/ProfileSettings.tsx create mode 100644 src/pages/settings/SecuritySettings.tsx diff --git a/package.json b/package.json index f24cd3f..9e18e99 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-table": "^8.21.3", "axios": "^1.18.1", "lucide-react": "^1.26.0", "react": "^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": { "@tailwindcss/postcss": "^4.3.3", diff --git a/src/App.tsx b/src/App.tsx index dcb11ac..b79be90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 Pharmacy from './pages/Pharmacy'; import Inventory from './pages/Inventory'; +import Patients from './pages/Patients'; import Prescriptions from './pages/Prescriptions'; import History from './pages/History'; 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 { VoiceAssistant } from './components/VoiceAssistant'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; + +const ProtectedLayout = () => { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return ( +
+ +
+ +
+ +
+ ); +}; function App() { return ( - -
- -
- + + + + } /> + + }> } /> } /> - Patients View
} /> + } /> } /> } /> + } /> } /> } /> - Settings View} /> + } /> + } /> + } /> + } /> } /> - - - - {/* Global AI Voice Assistant */} - - -
+ + + {/* Catch all route */} + } /> + + + ); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 489bfea..91886bc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,8 +1,24 @@ import React from 'react'; -import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, Users, Pill, Settings, HeartPulse, Box, Clock, ClipboardList } from 'lucide-react'; +import { NavLink, useNavigate } from 'react-router-dom'; +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 { 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 ( ); diff --git a/src/components/VoiceAssistant.tsx b/src/components/VoiceAssistant.tsx index 12df91d..97b91d9 100644 --- a/src/components/VoiceAssistant.tsx +++ b/src/components/VoiceAssistant.tsx @@ -121,6 +121,16 @@ export const VoiceAssistant: React.FC = () => { navigate('/prescriptions'); } else if (result.actionCommand === 'NAVIGATE_CATALOGUE') { 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) { console.error("AI command error:", error); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..a7c4d17 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +interface AuthContextType { + isAuthenticated: boolean; + login: () => void; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(() => { + 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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index c98faf6..9f86336 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,9 +1,16 @@ import { Activity, Package, Pill, Users } from 'lucide-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 [inventoryCount, setInventoryCount] = useState('...'); + const [metrics, setMetrics] = useState({ + totalPatients: '...', + totalPrescriptions: '...', + inventoryItems: '...', + dispensedToday: '...', + trend: [] as any[] + }); const [recentLogs, setRecentLogs] = useState([]); useEffect(() => { @@ -12,9 +19,16 @@ const Dashboard = () => { if (!localStorage.getItem('auth_token')) { await authenticate(); } - const data = await getExpiringInventory(); - if (data && data.batches) { - setInventoryCount(data.batches.length); + + const metricsData = await getDashboardMetrics(); + 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(); @@ -23,72 +37,142 @@ const Dashboard = () => { } } catch (err) { console.error("Failed to load dashboard data", err); - setInventoryCount('Error'); + setMetrics({ + totalPatients: 'Error', + totalPrescriptions: 'Error', + inventoryItems: 'Error', + dispensedToday: 'Error', + trend: [] + }); } }; fetchData(); }, []); const stats = [ - { label: 'Total Patients', value: '1,234', icon: Users, color: 'text-blue-400' }, - { label: 'Prescriptions', value: '856', icon: Activity, color: 'text-green-400' }, - { label: 'Inventory Items', value: inventoryCount, icon: Package, color: 'text-purple-400' }, - { label: 'Dispensed Today', value: '142', icon: Pill, color: 'text-pink-400' }, + { label: 'Total Patients', value: metrics.totalPatients, icon: Users, color: 'text-blue-600', gradient: 'from-blue-400 to-blue-600' }, + { label: 'Prescriptions', value: metrics.totalPrescriptions, icon: Activity, color: 'text-emerald-600', gradient: 'from-emerald-400 to-emerald-600' }, + { label: 'Inventory Items', value: metrics.inventoryItems, icon: Package, color: 'text-purple-600', gradient: 'from-purple-400 to-purple-600' }, + { label: 'Dispensed Today', value: metrics.dispensedToday, icon: Pill, color: 'text-pink-600', gradient: 'from-pink-400 to-pink-600' }, ]; return ( -
+
-

AI Pharmacy Dashboard

-

Welcome to your intelligent pharmacy management system.

+

AI Pharmacy Dashboard

+

Welcome to your intelligent pharmacy management system.

-
+ {/* Stats Grid */} +
{stats.map((stat, idx) => ( -
-
- -
-
-

{stat.label}

-

{stat.value}

+
+ {/* Glow Effect */} +
+ +
+
+ +
+
+

{stat.label}

+

{stat.value}

+
))}
-
-
-

Recent Activity

-
+ {/* Main Content Grid */} +
+ + {/* Chart Section */} +
+
+
+

Stock Movements

+

Items received vs dispensed over the last 7 days

+
+ +
+ {metrics.trend.length > 0 ? ( + + + + + + + + + + + + + + + + + + + + + ) : ( +
+ +

No trend data available for the past week.

+
+ )} +
+
+ + {/* Recent Activity Section */} +
+
+

Recent Activity

+ +
+ +
{recentLogs.length > 0 ? recentLogs.map((log) => { const isReceipt = log.movement_type === 'receipt'; const date = new Date(log.created_at); return ( -
-
-

{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}

-

- {isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no}) +

+
+ {isReceipt ? : } +
+
+

+ {isReceipt ? 'Stock Replenished' : 'Medication Dispensed'} +

+

+ {isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} units (Batch: {log.batch_no}) +

+

+ {date.toLocaleDateString()} • {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

- {date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
); }) : ( -

No recent activity.

+
+
+ +
+

All caught up!

+

No recent activity found.

+
)}
- -
-

Quick Actions

-
- - - -
-
); diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx index 2b82c68..363bf75 100644 --- a/src/pages/Inventory.tsx +++ b/src/pages/Inventory.tsx @@ -4,7 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table'; import { Table } from '../components/Table'; import Pagination from '../components/Pagination'; import { CustomSelect } from '../components/CustomSelect'; -import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine } from '../services/api'; +import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine, adjustStock } from '../services/api'; interface InventoryBatch { batch_no: string; @@ -74,6 +74,19 @@ const columns = [ ); } }), + columnHelper.display({ + id: 'actions', + header: '', + size: 100, + cell: info => ( + + ) + }), ]; const Inventory = () => { @@ -88,6 +101,7 @@ const Inventory = () => { const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all'); const [showFilterMenu, setShowFilterMenu] = useState(false); const [drugOptions, setDrugOptions] = useState([]); + const [supplierOptions, setSupplierOptions] = useState([]); const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock'); const [newMedicine, setNewMedicine] = useState({ @@ -96,7 +110,9 @@ const Inventory = () => { manufacturer: '', form: 'tablet', strength: '', - schedule: 'H' + schedule: 'H', + mrp: '', + pack_size: '1' }); const [formData, setFormData] = useState({ @@ -109,6 +125,32 @@ const Inventory = () => { purchase_price: '' }); + const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false); + const [selectedBatch, setSelectedBatch] = useState(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 () => { try { setIsAddingStock(true); @@ -123,7 +165,9 @@ const Inventory = () => { manufacturer: newMedicine.manufacturer, form: newMedicine.form, 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); if (medRes && medRes.product_id) { @@ -155,7 +199,7 @@ const Inventory = () => { // Reset state 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: '' }); alert("Stock added successfully! Inventory count increased."); @@ -198,6 +242,16 @@ const Inventory = () => { if (drugsData && 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) { if (err.response && err.response.status === 401) { try { @@ -229,8 +283,19 @@ const Inventory = () => { 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); - return () => window.removeEventListener('setFilter', handleSetFilter); + window.addEventListener('openAdjustModal', handleOpenAdjust); + return () => { + window.removeEventListener('setFilter', handleSetFilter); + window.removeEventListener('openAdjustModal', handleOpenAdjust); + }; }, []); // Filter based on search query @@ -459,6 +524,14 @@ const Inventory = () => { 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" />
+
+ + 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" /> +
+
+ + 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" /> +
+ +
+
@@ -541,7 +618,7 @@ const Inventory = () => {
{/* Quantity */}
- +
@@ -550,7 +627,7 @@ const Inventory = () => { type="number" value={formData.qty} 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" style={{ padding: '12px 16px', paddingLeft: '44px' }} /> @@ -604,6 +681,94 @@ const Inventory = () => {
)} + + {/* Adjust Stock Modal */} + {isAdjustModalOpen && selectedBatch && ( +
+
+
+

Adjust Stock

+ +
+ +
+
+

Batch Info

+

{selectedBatch.brand_name}

+
+ Batch: {selectedBatch.batch_no} + Current Qty: {selectedBatch.qty_on_hand} +
+
+ +
+ + 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' }} + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ )}
); }; diff --git a/src/pages/Login.css b/src/pages/Login.css new file mode 100644 index 0000000..92f717f --- /dev/null +++ b/src/pages/Login.css @@ -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; +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..c2ed25f --- /dev/null +++ b/src/pages/Login.tsx @@ -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 ( +
+
+
+

AI Pharmacy

+

Sign in to your account

+
+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Enter your password" + required + /> + +
+
+ + +
+
+
+ ); +}; + +export default Login; diff --git a/src/pages/Patients.tsx b/src/pages/Patients.tsx new file mode 100644 index 0000000..2d81589 --- /dev/null +++ b/src/pages/Patients.tsx @@ -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(); + +const columns = [ + columnHelper.accessor('full_name', { + header: 'Patient', + size: 350, + cell: info => { + const patient = info.row.original; + return ( +
+
+
+ +
+
+
{patient.full_name}
+
ID: {patient.hospital_patient_id}
+
+
+
+ ); + } + }), + columnHelper.accessor('contact_number', { + header: 'Contact', + size: 200, + cell: info => ( +
+ + {info.getValue() || 'N/A'} +
+ ) + }), + columnHelper.accessor('email', { + header: 'Email', + size: 250, + cell: info => ( +
+ + {info.getValue() || 'N/A'} +
+ ) + }), + columnHelper.accessor('created_at', { + header: 'Synced At', + size: 200, + cell: info => ( +
+ + {new Date(info.getValue()).toLocaleString()} +
+ ) + }) +]; + +export default function Patients() { + const [patients, setPatients] = useState([]); + 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 ( +
+
+
+ ); + } + + + + const totalItems = filteredPatients.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const currentData = filteredPatients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + + return ( +
+
+
+

+ Hospital Patient Directory +

+

+ + Securely Synced via AI Integration +

+
+ +
+
+
+ +
+ { + 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' }} + /> +
+
+ {filteredPatients.length} Records +
+
+
+ +
+
+ {(!Array.isArray(patients) || patients.length === 0) ? ( +
+ +

No Patients Found

+

No patients have been synced from the hospital yet.

+
+ ) : filteredPatients.length === 0 ? ( +
+ +

No Matches Found

+

No patients match your search "{searchQuery}".

+
+ ) : ( + item.id} + emptyMessage="No patients found" + /> + )} + + + {totalItems > 0 && ( + setCurrentPage(page)} + onItemsPerPageChange={(limit) => { + setItemsPerPage(limit); + setCurrentPage(1); + }} + /> + )} + + + ); +} diff --git a/src/pages/Pharmacy.tsx b/src/pages/Pharmacy.tsx index d4ed64e..bb0d7e1 100644 --- a/src/pages/Pharmacy.tsx +++ b/src/pages/Pharmacy.tsx @@ -55,8 +55,8 @@ const columns = [ } }), columnHelper.accessor('price', { - header: 'Price', - size: 120, + header: 'Purchase Price / MRP', + size: 150, cell: info => {info.getValue()} }), columnHelper.accessor('manufacture', { @@ -89,6 +89,7 @@ const Pharmacy = () => { form: 'tablet', strength: '', mrp: '', + pack_size: '1', schedule: 'H' }); @@ -160,7 +161,7 @@ const Pharmacy = () => { setIsAdding(true); await addMedicine(addFormData); 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(); } catch (err) { console.error("Failed to add medicine", err); @@ -177,8 +178,8 @@ const Pharmacy = () => { subtitle: drug.generic_name || '', badge: drug.schedule || '', stock: parseInt(drug.total_stock) || 0, - rawPrice: parseFloat(drug.mrp) || 0, - price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.mrp) || 0), + rawPrice: parseFloat(drug.purchase_price) || 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', type: drug.form || 'Unknown' })); @@ -250,39 +251,48 @@ const Pharmacy = () => { return (
-
-
-

Pharmacy Overview

+
+
+
+ +
+

Pharmacy Overview

-
+

Manage your pharmacy inventory, add medicines, and handle manual dispensing.

+
{/* Metrics Row */}
-
-
- +
+
+
-
-

Total Medicines

-

{inventory.length}

+
+

Total Medicines

+

{inventory.length}

-
-
- + +
+ {lowStockCount > 0 && ( +
+ )} +
+
-
-

Low Stock Alerts

-

{lowStockCount}

+
+

Low Stock Alerts

+

0 ? 'text-red-600' : 'text-slate-900'}`}>{lowStockCount}

-
-
- + +
+
+
-
-

Unique Categories

-

{uniqueCategories}

+
+

Unique Categories

+

{uniqueCategories}

@@ -311,14 +321,6 @@ const Pharmacy = () => { Manual Dispense - -
@@ -422,6 +424,10 @@ const Pharmacy = () => { 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' }} />
+
+ + 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' }} /> +
diff --git a/src/pages/Prescriptions.tsx b/src/pages/Prescriptions.tsx index 4cbd4a9..8a230ab 100644 --- a/src/pages/Prescriptions.tsx +++ b/src/pages/Prescriptions.tsx @@ -1,5 +1,5 @@ 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 { createColumnHelper } from '@tanstack/react-table'; import { Table } from '../components/Table'; @@ -26,6 +26,7 @@ const Prescriptions = () => { const [dispenseSuccess, setDispenseSuccess] = useState(false); const [paymentMethod, setPaymentMethod] = useState('cash'); const [drugOptions, setDrugOptions] = useState([]); + const [mappedItems, setMappedItems] = useState>({}); // Queue State const [prescriptionsList, setPrescriptionsList] = useState([]); @@ -39,11 +40,39 @@ const Prescriptions = () => { }).catch(console.error); }, []); + useEffect(() => { + if (prescriptionData && drugOptions.length > 0) { + const items = prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []; + const newMapping: Record = {}; + + 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 () => { try { const data = await getAllPrescriptions(); 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) { console.error("Failed to load queue", err); @@ -131,14 +160,22 @@ const Prescriptions = () => { size: 150, cell: info => { const status = info.getValue(); + const isDigital = info.row.original.isDigital; return ( - - {status} - +
+ {isDigital && ( + + E-RX + + )} + + {status} + +
); } }), @@ -214,18 +251,13 @@ const Prescriptions = () => { allergies: [], conditions: [] }, - items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => { - const name = m.parsed?.brand || m.name || m.verbatim || ''; + items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => { + 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 => - 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; + let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity); if (!qty || isNaN(qty)) { const doseStr = m.parsed?.dose || m.dosage || ''; const match = doseStr.match(/(\d+)-(\d+)-(\d+)/); @@ -263,18 +295,13 @@ const Prescriptions = () => { const payload = { subject_ref: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown', prescriber_name: prescriptionData.extracted_data.prescriber?.name || 'Unknown Prescriber', - items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => { - const name = m.parsed?.brand || m.name || m.verbatim || ''; + items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => { + 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 => - 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; + let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity); if (!qty || isNaN(qty)) { // Try to extract qty from dosage like 1-0-1 const doseStr = m.parsed?.dose || m.dosage || ''; @@ -406,52 +433,107 @@ const Prescriptions = () => {
-
-
- - setPrescriptionData({ - ...prescriptionData, - extracted_data: { - ...prescriptionData.extracted_data, - patient: { ...prescriptionData.extracted_data?.patient, name: e.target.value }, - patient_name: e.target.value - } - })} - className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - setPrescriptionData({ - ...prescriptionData, - extracted_data: { - ...prescriptionData.extracted_data, - prescriber: { ...prescriptionData.extracted_data?.prescriber, name: e.target.value }, - doctor_name: e.target.value - } - })} - className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> +
+
+
+ + setPrescriptionData({ + ...prescriptionData, + extracted_data: { + ...prescriptionData.extracted_data, + patient: { ...prescriptionData.extracted_data?.patient, name: e.target.value }, + patient_name: e.target.value + } + })} + className="w-full px-4 py-2 bg-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" + /> +
+ +
+ + 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" + /> +
-
- -
+
+
+ +

Prescribed Medications

+ + {(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).length} Items + +
+ +
{(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).map((med: any, idx: number) => ( -
- {med.parsed?.brand || med.name || med.verbatim} - {med.parsed?.dose || med.dosage} +
+
+
+

{med.parsed?.brand || med.name || med.verbatim}

+
+ {med.parsed?.dose || med.dosage ? ( + {med.parsed?.dose || med.dosage} + ) : null} + {med.parsed?.total_qty_str && ( + Qty: {med.parsed.total_qty_str} + )} + {med.parsed?.duration && ( + {med.parsed.duration} + )} +
+
+
+ +
+
+ + +
+ + {med.parsed?.instruction && ( +
+ +
+ {med.parsed.instruction} +
+
+ )} +
))} {!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && ( -

No medicines detected.

+
+ +

No medicines detected.

+
)}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..f676215 --- /dev/null +++ b/src/pages/Settings.tsx @@ -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: , + color: 'blue', + path: '/settings/profile' + }, + { + id: 'security', + title: 'Change Password', + description: 'Update your account password and manage security preferences.', + icon: , + color: 'emerald', + path: '/settings/security' + } + ]; + + return ( +
+
+
+

Settings

+

Manage your account and application preferences

+
+
+ +
+ {settingsCards.map((card) => ( +
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" + > +
+
+
+ {card.icon} +
+
+

{card.title}

+

{card.description}

+
+
+
+ +
+
+
+ ))} +
+
+ ); +}; + +export default Settings; diff --git a/src/pages/StockMovements.tsx b/src/pages/StockMovements.tsx new file mode 100644 index 0000000..0e4e9a2 --- /dev/null +++ b/src/pages/StockMovements.tsx @@ -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(); + +const columns = [ + columnHelper.accessor('created_at', { + header: 'Date & Time', + size: 200, + cell: info => { + const date = new Date(info.getValue()); + return ( +
+ {date.toLocaleDateString()} + + {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ ); + } + }), + columnHelper.accessor('movement_type', { + header: 'Movement Type', + size: 200, + cell: info => { + const type = info.getValue(); + const isReceipt = type === 'receipt'; + return ( +
+ {isReceipt ? : } + {isReceipt ? 'Stock Received' : 'Dispensed'} +
+ ); + } + }), + columnHelper.accessor('batch_no', { + header: 'Batch Number', + size: 200, + cell: info => ( +
+ + {info.getValue() || 'N/A'} +
+ ) + }), + columnHelper.accessor('qty_delta', { + header: 'Quantity', + size: 150, + cell: info => { + const qty = info.getValue(); + const isPositive = qty > 0; + return ( + + {isPositive ? '+' : ''}{qty} + + ); + } + }) +]; + +export default function StockMovements() { + const [logs, setLogs] = useState([]); + 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 ( +
+
+
+
+
+ +
+

Stock Movements

+
+

+ Detailed history of inventory receipts and dispensations. +

+
+ +
+
+
+ +
+ 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' }} + /> +
+
+ {filteredLogs.length} Records +
+
+
+ +
+ {loading ? ( +
+
+
+ ) : ( + <> +
+ {totalItems > 0 && ( +
+ setCurrentPage(page)} + onItemsPerPageChange={(limit) => { + setItemsPerPage(limit); + setCurrentPage(1); + }} + /> +
+ )} + + )} + + + ); +} diff --git a/src/pages/Suppliers.tsx b/src/pages/Suppliers.tsx new file mode 100644 index 0000000..1013b19 --- /dev/null +++ b/src/pages/Suppliers.tsx @@ -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(); + +const columns = [ + columnHelper.accessor('name', { + header: 'COMPANY NAME', + size: 250, + cell: info => {info.getValue()} + }), + columnHelper.accessor('contact_person', { + header: 'CONTACT PERSON', + size: 180, + cell: info => {info.getValue() || '-'} + }), + columnHelper.accessor('contact_number', { + header: 'PHONE', + size: 150, + cell: info => {info.getValue() || '-'} + }), + columnHelper.accessor('email', { + header: 'EMAIL', + size: 200, + cell: info => {info.getValue() || '-'} + }), + columnHelper.accessor('dl_number', { + header: 'DL NUMBER', + size: 180, + cell: info => ( + + {info.getValue() || '-'} + + ) + }), + columnHelper.accessor('gstin', { + header: 'GSTIN', + size: 180, + cell: info => ( + + {info.getValue() || '-'} + + ) + }), + columnHelper.accessor('address', { + header: 'ADDRESS', + size: 300, + cell: info => {info.getValue() || '-'} + }) +]; + +const Suppliers = () => { + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(25); + const [suppliers, setSuppliers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isAddingSupplier, setIsAddingSupplier] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+

Suppliers & Distributors

+

Manage your wholesale medicine suppliers.

+
+
+ + {/* Action Bar */} +
+
+
+ +
+ 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' }} + /> +
+
+ +
+
+ + {/* Table Card */} +
+
+ {error ? ( +
+ +

{error}

+ +
+ ) : isLoading ? ( +
+
+

Loading suppliers...

+
+ ) : suppliers.length === 0 ? ( +
+ +

No Suppliers Found

+

You haven't added any wholesale distributors yet.

+ +
+ ) : ( +
item.id} + /> + )} + + {totalItems > 0 && !isLoading && !error && ( + setCurrentPage(page)} + onItemsPerPageChange={(limit) => { + setItemsPerPage(limit); + setCurrentPage(1); + }} + /> + )} + + + {/* Add Supplier Modal */} + {isModalOpen && ( +
+
+
+

+ + Register New Supplier +

+ +
+ +
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+ 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' }} + /> +
+
+ +
+ +
+
+ +
+