feat: implement core pharmacy management dashboard and navigation infrastructure
This commit is contained in:
+3
-1
@@ -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",
|
||||||
|
|||||||
+47
-16
@@ -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 />;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<Routes>
|
<Outlet />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="/pharmacy" element={<Pharmacy />} />
|
|
||||||
<Route path="/patients" element={<div>Patients View</div>} />
|
|
||||||
<Route path="/history" element={<History />} />
|
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
|
||||||
<Route path="/prescriptions" element={<Prescriptions />} />
|
|
||||||
<Route path="/registers" element={<Registers />} />
|
|
||||||
<Route path="/settings" element={<div>Settings View</div>} />
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Global AI Voice Assistant */}
|
|
||||||
<VoiceAssistant />
|
<VoiceAssistant />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
<Route element={<ProtectedLayout />}>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/pharmacy" element={<Pharmacy />} />
|
||||||
|
<Route path="/patients" element={<Patients />} />
|
||||||
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
|
<Route path="/suppliers" element={<Suppliers />} />
|
||||||
|
<Route path="/prescriptions" element={<Prescriptions />} />
|
||||||
|
<Route path="/registers" element={<Registers />} />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Catch all route */}
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
+127
-43
@@ -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 className="flex items-center gap-5 relative z-10">
|
||||||
|
<div className={`p-4 rounded-2xl bg-white shadow-sm border border-slate-100 ${stat.color} group-hover:scale-110 transition-transform duration-300`}>
|
||||||
|
<stat.icon size={26} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">{stat.label}</p>
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">{stat.label}</p>
|
||||||
<p className="text-2xl font-bold">{stat.value}</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>
|
|
||||||
<p className="font-medium">{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}</p>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no})
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400">{date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
||||||
|
<div className="flex-1 min-h-[350px] w-full">
|
||||||
|
{metrics.trend.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={metrics.trend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<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}/>
|
||||||
|
</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>
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<p className="text-sm text-gray-400">No recent activity.</p>
|
|
||||||
)}
|
)}
|
||||||
</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
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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>
|
||||||
|
<p className="text-slate-500 mt-2">Manage your pharmacy inventory, add medicines, and handle manual dispensing.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 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' }}>
|
||||||
|
|||||||
+120
-38
@@ -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,7 +160,14 @@ 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 (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{isDigital && (
|
||||||
|
<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">
|
||||||
|
E-RX
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={`px-2.5 py-1 rounded-md text-[11px] font-bold tracking-wide uppercase ${
|
<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 === 'confirmed' ? 'bg-emerald-100 text-emerald-700 border border-emerald-200' :
|
||||||
status === 'processing' ? 'bg-amber-100 text-amber-700 border border-amber-200' :
|
status === 'processing' ? 'bg-amber-100 text-amber-700 border border-amber-200' :
|
||||||
@@ -139,6 +175,7 @@ const Prescriptions = () => {
|
|||||||
}`}>
|
}`}>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</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,9 +433,12 @@ 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">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||||
|
<User size={16} className="text-slate-400" /> Patient Name
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''}
|
value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''}
|
||||||
@@ -420,12 +450,14 @@ const Prescriptions = () => {
|
|||||||
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"
|
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 className="flex flex-col gap-2">
|
||||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Doctor</label>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={prescriptionData.extracted_data?.prescriber?.name || prescriptionData.extracted_data?.doctor_name || ''}
|
value={prescriptionData.extracted_data?.prescriber?.name || prescriptionData.extracted_data?.doctor_name || ''}
|
||||||
@@ -437,21 +469,71 @@ const Prescriptions = () => {
|
|||||||
doctor_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"
|
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">Prescribed Medicines</label>
|
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
|
||||||
<div className="space-y-2">
|
<Pill className="text-blue-500" size={20} />
|
||||||
|
<h3 className="font-semibold text-slate-800 text-lg">Prescribed Medications</h3>
|
||||||
|
<span className="ml-auto bg-slate-100 text-slate-600 py-1 px-3 rounded-full text-xs font-semibold">
|
||||||
|
{(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).length} Items
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{(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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user