Update frontend code

This commit is contained in:
2026-09-02 18:23:05 +05:30
parent 8c25b84616
commit d46f78bbd0
25 changed files with 1213 additions and 279 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://localhost:8080/v1
VITE_API_KEY=915e46784e798f21759e52bd2a9d4cc2
+16
View File
@@ -0,0 +1,16 @@
const fs = require('fs');
const path = require('path');
const dir = './src/pages';
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
for (const file of files) {
const filePath = path.join(dir, file);
let content = fs.readFileSync(filePath, 'utf8');
// Replace the style attribute for the input to include height: '64px'
// It currently looks like: style={{ paddingLeft: '60px', paddingRight: '16px', fontSize: '16px' }}
content = content.replace(/style=\{\{ paddingLeft: '60px', paddingRight: '16px', fontSize: '16px' \}\}/g, "style={{ height: '64px', paddingLeft: '60px', paddingRight: '16px', fontSize: '18px' }}");
fs.writeFileSync(filePath, content, 'utf8');
}
console.log('Added explicit inline height 64px to search inputs.');
+17
View File
@@ -0,0 +1,17 @@
const fs = require('fs');
const path = require('path');
const dir = './src/pages';
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
for (const file of files) {
const filePath = path.join(dir, file);
let content = fs.readFileSync(filePath, 'utf8');
// Make the search bar wide by removing the max width restriction and adding flex-1
content = content.replace(/className="relative w-full max-w-2xl group"/g, 'className="relative w-full flex-1 max-w-none group"');
// Also check if some of them were modified differently
content = content.replace(/className="relative w-full max-w-md group"/g, 'className="relative w-full flex-1 max-w-none group"');
fs.writeFileSync(filePath, content, 'utf8');
}
console.log('Made search bars full width.');
View File
+420 -90
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
const fs = require('fs');
const path = require('path');
const dir = './src/pages';
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
for (const file of files) {
const filePath = path.join(dir, file);
let content = fs.readFileSync(filePath, 'utf8');
// Reduce height from 64px to 54px, and adjust font size back to 16px
content = content.replace(/style=\{\{ height: '64px', paddingLeft: '60px', paddingRight: '16px', fontSize: '18px' \}\}/g, "style={{ height: '54px', paddingLeft: '56px', paddingRight: '16px', fontSize: '16px' }}");
// Also reduce the Search icon size from 28 to 24
content = content.replace(/<Search size=\{28\}/g, '<Search size={24}');
// Adjust icon padding so it's centered perfectly
content = content.replace(/style=\{\{ paddingLeft: '24px' \}\}/g, "style={{ paddingLeft: '20px' }}");
fs.writeFileSync(filePath, content, 'utf8');
}
console.log('Reduced search bar height to 54px.');
+24
View File
@@ -0,0 +1,24 @@
const fs = require('fs');
const path = require('path');
const dir = './src/pages';
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
for (const file of files) {
const filePath = path.join(dir, file);
let content = fs.readFileSync(filePath, 'utf8');
// Reduce height from 54px to 46px, font size to 15px
content = content.replace(/style=\{\{ height: '54px', paddingLeft: '56px', paddingRight: '16px', fontSize: '16px' \}\}/g, "style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}");
// Reduce the Search icon size from 24 to 20
content = content.replace(/<Search size=\{24\}/g, '<Search size={20}');
// Adjust icon padding so it's centered perfectly
content = content.replace(/style=\{\{ paddingLeft: '20px' \}\}/g, "style={{ paddingLeft: '16px' }}");
// Remove py-5 and text-lg because we are setting height directly anyway, but let's keep it clean
content = content.replace(/className="w-full py-5 text-lg /g, 'className="w-full py-3 ');
fs.writeFileSync(filePath, content, 'utf8');
}
console.log('Reduced search bar height to 46px.');
+8 -3
View File
@@ -16,6 +16,8 @@ import Sidebar from './components/Sidebar';
import { VoiceAssistant } from './components/VoiceAssistant'; import { VoiceAssistant } from './components/VoiceAssistant';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import { Header } from './components/Header';
const ProtectedLayout = () => { const ProtectedLayout = () => {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
@@ -26,9 +28,12 @@ const ProtectedLayout = () => {
return ( return (
<div className="app-container"> <div className="app-container">
<Sidebar /> <Sidebar />
<main className="main-content"> <div className="flex-1 flex flex-col h-screen overflow-hidden">
<Outlet /> <Header />
</main> <main className="main-content flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
<VoiceAssistant /> <VoiceAssistant />
</div> </div>
); );
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Sparkles, Loader2, Bot } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { sendVoiceCommand } from '../services/api';
export const AIChatWidget = () => {
const [query, setQuery] = useState('');
const [messages, setMessages] = useState<{role: 'user' | 'ai', text: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
const userText = query.trim();
setQuery('');
setMessages(prev => [...prev, { role: 'user', text: userText }]);
setIsLoading(true);
try {
const result = await sendVoiceCommand(userText);
setMessages(prev => [...prev, {
role: 'ai',
text: result.replyText || "Action completed."
}]);
handleActionCommand(result.actionCommand);
} catch (error) {
console.error("AI command error:", error);
setMessages(prev => [...prev, {
role: 'ai',
text: "Sorry, there was an error processing your request."
}]);
} finally {
setIsLoading(false);
}
};
const handleActionCommand = (actionCommand: string) => {
if (!actionCommand || actionCommand === 'NONE') return;
if (actionCommand === 'VIEW_LOW_STOCK') {
navigate('/inventory');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('setFilter', { detail: 'low-stock' }));
}, 500);
} else if (actionCommand === 'VIEW_EXPIRING_STOCK') {
navigate('/inventory');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('setFilter', { detail: 'expiring' }));
}, 500);
} else if (actionCommand === 'NAVIGATE_DASHBOARD') {
navigate('/');
} else if (actionCommand === 'NAVIGATE_PHARMACY') {
navigate('/pharmacy');
} else if (actionCommand === 'NAVIGATE_INVENTORY') {
navigate('/inventory');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('setFilter', { detail: 'all' }));
}, 500);
} else if (actionCommand === 'NAVIGATE_PRESCRIPTIONS') {
navigate('/prescriptions');
} else if (actionCommand === 'NAVIGATE_PATIENTS') {
navigate('/patients');
} else if (actionCommand === 'NAVIGATE_SUPPLIERS') {
navigate('/suppliers');
} else if (actionCommand === 'NAVIGATE_REGISTERS') {
navigate('/registers');
} else if (actionCommand === 'NAVIGATE_STOCK_MOVEMENTS') {
navigate('/stock-movements');
} else if (actionCommand === 'NAVIGATE_HISTORY') {
navigate('/history');
}
};
return (
<div className="glass-panel w-full flex flex-col hover:shadow-lg transition-shadow duration-300 h-[450px]">
<div className="flex items-center gap-2 mb-4 border-b border-slate-100 pb-4">
<div className="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
<Sparkles size={20} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-800">Smart Insights</h2>
<p className="text-sm text-slate-500">Ask anything about your pharmacy data</p>
</div>
</div>
<div className="flex-1 overflow-y-auto mb-4 space-y-4 custom-scrollbar pr-2">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 opacity-70">
<Bot size={48} className="mb-3" />
<p className="text-sm">Try asking "how many patients do we have?"</p>
<p className="text-sm">or "show low stock items"</p>
</div>
) : (
messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] p-3 rounded-2xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-slate-100 text-slate-800 rounded-tl-none border border-slate-200'
}`}
>
{msg.text}
</div>
</div>
))
)}
{isLoading && (
<div className="flex justify-start">
<div className="bg-slate-100 p-3 rounded-2xl rounded-tl-none border border-slate-200">
<Loader2 size={16} className="animate-spin text-slate-500" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ask AI..."
className="w-full pl-4 pr-12 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 bg-white shadow-sm transition-all text-sm"
disabled={isLoading}
/>
<button
type="submit"
disabled={!query.trim() || isLoading}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:hover:bg-indigo-600 transition-colors"
>
<Send size={16} />
</button>
</form>
</div>
);
};
+103
View File
@@ -0,0 +1,103 @@
import { useState, useRef, useEffect } from 'react';
import { User, Settings, LogOut, ChevronDown } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import { logoutPharmacist, getMe } from '../services/api';
export const Header = () => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
console.log("Updated Header Component Loaded v2!");
const { logout } = useAuth();
const navigate = useNavigate();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
const fetchProfile = async () => {
try {
const data = await getMe();
if (data && data.profile) {
setUserProfile(data.profile);
}
} catch (err) {
console.error("Failed to load profile in header", err);
}
};
fetchProfile();
}, []);
const handleLogout = async () => {
try {
await logoutPharmacist();
} catch (e) {
console.error(e);
} finally {
logout();
navigate('/login');
}
};
return (
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-end px-6 sticky top-0 z-40 w-full shadow-sm">
<div className="relative flex items-center" ref={dropdownRef}>
<div className="flex items-center gap-3 mr-4">
<div className="w-9 h-9 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold overflow-hidden border-2 border-indigo-200">
{userProfile?.profile_image_url ? (
<img src={userProfile.profile_image_url} alt="Profile" className="w-full h-full object-cover" />
) : (
<User size={18} />
)}
</div>
<div className="flex flex-col items-start hidden sm:flex">
<span className="text-sm font-bold text-slate-800 leading-tight">
{userProfile?.pharmacy_name || userProfile?.username || 'Admin User'}
</span>
<span className="text-xs text-slate-500 font-medium capitalize">
{userProfile?.role || 'Pharmacist'}
</span>
</div>
</div>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center justify-center w-10 h-10 hover:bg-slate-100 rounded-full transition-colors text-slate-600 hover:text-slate-900"
title="Account Menu"
>
<LogOut size={20} />
</button>
{isDropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-xl border border-slate-200 p-2 animate-in fade-in slide-in-from-top-2">
<button
onClick={() => { setIsDropdownOpen(false); navigate('/settings'); }}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 rounded-lg transition-colors"
>
<Settings size={18} className="text-slate-400" />
Settings
</button>
<div className="h-px bg-slate-100 my-3 mx-2"></div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-rose-600 hover:bg-rose-50 hover:text-rose-700 rounded-lg transition-colors"
>
<LogOut size={18} />
Logout
</button>
</div>
)}
</div>
</header>
);
};
+6 -6
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, Pill, Settings, HeartPulse, Box, Clock, ClipboardList, Activity, Truck, LogOut } from 'lucide-react'; import { LayoutDashboard, Users, Pill, Settings, Cross, Box, Clock, ClipboardList, Activity, Truck, LogOut } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { logoutPharmacist } from '../services/api'; import { logoutPharmacist } from '../services/api';
@@ -22,7 +22,7 @@ const Sidebar: React.FC = () => {
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-logo"> <div className="sidebar-logo">
<HeartPulse size={28} color="#a5b4fc" /> <Cross size={28} color="#a5b4fc" />
<span>PharmaAI</span> <span>PharmaAI</span>
</div> </div>
@@ -99,16 +99,16 @@ const Sidebar: React.FC = () => {
<span>Suppliers</span> <span>Suppliers</span>
</NavLink> </NavLink>
<NavLink {/* <NavLink
to="/settings" to="/settings"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
> >
<Settings className="nav-item-icon" /> <Settings className="nav-item-icon" />
<span>Settings</span> <span>Settings</span>
</NavLink> </NavLink> */}
</nav> </nav>
<div className="sidebar-footer" style={{ marginTop: 'auto' }}> {/* <div className="sidebar-footer" style={{ marginTop: 'auto' }}>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="nav-item" className="nav-item"
@@ -117,7 +117,7 @@ const Sidebar: React.FC = () => {
<LogOut className="nav-item-icon" /> <LogOut className="nav-item-icon" />
<span style={{ fontSize: '1rem' }}>Logout</span> <span style={{ fontSize: '1rem' }}>Logout</span>
</button> </button>
</div> </div> */}
</aside> </aside>
); );
}; };
+3 -4
View File
@@ -76,7 +76,7 @@ export const VoiceAssistant: React.FC = () => {
// We need a separate effect to trigger processing once listening stops and we have a final transcript // We need a separate effect to trigger processing once listening stops and we have a final transcript
useEffect(() => { useEffect(() => {
if (!isListening && transcript) { if (!isListening && transcript) {
processCommand(transcript); processCommand(transcript);
} }
}, [isListening, transcript]); }, [isListening, transcript]);
@@ -203,11 +203,10 @@ export const VoiceAssistant: React.FC = () => {
{/* Floating Action Button */} {/* Floating Action Button */}
<button <button
onClick={toggleListening} onClick={toggleListening}
className={`relative flex items-center justify-center w-16 h-16 rounded-full shadow-2xl transition-all duration-300 pointer-events-auto border-4 ${ className={`relative flex items-center justify-center w-16 h-16 rounded-full shadow-2xl transition-all duration-300 pointer-events-auto border-4 ${isListening
isListening
? 'bg-rose-500 hover:bg-rose-600 scale-110 shadow-rose-500/50 border-rose-400' ? 'bg-rose-500 hover:bg-rose-600 scale-110 shadow-rose-500/50 border-rose-400'
: 'bg-slate-900 hover:bg-slate-800 shadow-slate-900/30 border-white' : 'bg-slate-900 hover:bg-slate-800 shadow-slate-900/30 border-white'
}`} }`}
> >
{isListening ? ( {isListening ? (
<MicOff className="text-white w-7 h-7" /> <MicOff className="text-white w-7 h-7" />
+4
View File
@@ -205,3 +205,7 @@ h1 {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1.5rem; gap: 1.5rem;
} }
/* trigger update */
/* trigger update 2 */
/* trigger update dropdown */
/* trigger update for button */
+33 -12
View File
@@ -1,4 +1,4 @@
import { Activity, Package, Pill, Users } from 'lucide-react'; import { Activity, Package, Pill, Users, AlertCircle } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { authenticate, getAuditLogs, getDashboardMetrics } from '../services/api'; import { authenticate, getAuditLogs, getDashboardMetrics } from '../services/api';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
@@ -87,7 +87,7 @@ const Dashboard = () => {
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Chart Section */} {/* Chart Section */}
<div className="glass-panel xl:col-span-2 flex flex-col hover:shadow-lg transition-shadow duration-300 relative overflow-hidden"> <div className="glass-panel xl:col-span-3 flex flex-col hover:shadow-lg transition-shadow duration-300 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-500 opacity-50"></div> <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-500 opacity-50"></div>
<div className="mb-6"> <div className="mb-6">
<h2 className="text-xl font-bold text-slate-800">Stock Movements</h2> <h2 className="text-xl font-bold text-slate-800">Stock Movements</h2>
@@ -128,35 +128,56 @@ const Dashboard = () => {
</div> </div>
</div> </div>
{/* Recent Activity Section */} {/* Recent Activity Section */}
<div className="glass-panel flex flex-col hover:shadow-lg transition-shadow duration-300"> <div className="glass-panel xl:col-span-3 flex flex-col hover:shadow-lg transition-shadow duration-300 max-h-[400px]">
<div className="flex justify-between items-center mb-6 border-b border-slate-100 pb-4"> <div className="flex justify-between items-center mb-6 border-b border-slate-100 pb-4">
<h2 className="text-xl font-bold text-slate-800">Recent Activity</h2> <h2 className="text-xl font-bold text-slate-800">Recent Activity</h2>
<button <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" 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'} onClick={() => window.location.href = '/pharmacy?action=dispense'}
> >
+ Quick Rx + Quick Rx
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar"> <div className="flex-1 overflow-y-auto pr-2 flex flex-col gap-4 custom-scrollbar">
{recentLogs.length > 0 ? recentLogs.map((log) => { {recentLogs.length > 0 ? recentLogs.map((log) => {
const isReceipt = log.movement_type === 'receipt'; const isReceipt = log.movement_type === 'receipt';
const isAdjustment = log.movement_type === 'adjustment';
const date = new Date(log.created_at); const date = new Date(log.created_at);
let bgColor = 'bg-pink-100 text-pink-600';
let icon = <Pill size={20} />;
let title = 'Medication Dispensed';
let actionText = 'Dispensed';
let actionValue = `${Math.abs(log.qty_delta || 0)} units`;
if (isReceipt) {
bgColor = 'bg-emerald-100 text-emerald-600';
icon = <Package size={20} />;
title = 'Stock Replenished';
actionText = 'Received';
} else if (isAdjustment) {
bgColor = 'bg-amber-100 text-amber-600';
icon = <AlertCircle size={20} />;
title = 'Stock Adjusted';
actionText = log.qty_delta < 0 ? 'Decreased by' : 'Increased by';
}
return ( 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 key={log.id} className="flex items-center p-4 rounded-xl bg-white border border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 transition-colors group mb-2 mx-1">
<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'}`}> <div className={`flex-shrink-0 w-10 h-10 ml-2 mr-4 rounded-full flex items-center justify-center ${bgColor}`}>
{isReceipt ? <Package size={16} /> : <Pill size={16} />} {icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-slate-800 text-sm truncate group-hover:text-blue-700 transition-colors"> <p className="font-semibold text-slate-800 text-sm truncate group-hover:text-blue-700 transition-colors">
{isReceipt ? 'Stock Replenished' : 'Medication Dispensed'} {title}
</p> </p>
<p className="text-xs text-slate-500 mt-0.5"> <p className="text-xs text-slate-500 mt-1">
{isReceipt ? 'Received' : 'Dispensed'} <span className="font-medium text-slate-700">{Math.abs(log.qty_delta)} units</span> (Batch: {log.batch_no}) {actionText} <span className="font-medium text-slate-700">{actionValue}</span> (Batch: {log.batch_no})
</p> </p>
<p className="text-[10px] text-slate-400 mt-1 font-medium"> <p className="text-[11px] text-slate-400 mt-1.5 font-medium">
{date.toLocaleDateString()} • {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {date.toLocaleDateString()} • {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p> </p>
</div> </div>
+26 -3
View File
@@ -3,7 +3,7 @@ import { History as HistoryIcon, Clock, Package, AlertTriangle, Search } from 'l
import { createColumnHelper } from '@tanstack/react-table'; 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 { getDispensingHistory } from '../services/api'; import { getDispensingHistory, downloadInvoice } from '../services/api';
interface DispensingHistoryRecord { interface DispensingHistoryRecord {
dispensing_id: string; dispensing_id: string;
@@ -93,6 +93,29 @@ const columns = [
); );
} }
}), }),
columnHelper.display({
id: 'action',
header: 'ACTION',
size: 150,
cell: info => {
const record = info.row.original;
return (
<button
onClick={async () => {
try {
await downloadInvoice(record.dispensing_id);
} catch (e) {
alert("Failed to download invoice");
console.error(e);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-[12px] font-bold transition-colors shadow-sm"
>
<Package size={14} /> Download Bill
</button>
);
}
}),
]; ];
const History = () => { const History = () => {
@@ -158,7 +181,7 @@ const History = () => {
{/* Action Bar with Spotlight Search */} {/* Action Bar with Spotlight Search */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}> <div className="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="relative w-full flex-1 max-w-none group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
@@ -168,7 +191,7 @@ const History = () => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by patient name, prescription ID or Dispensing ID..." placeholder="Search by patient name, prescription ID or Dispensing ID..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </div>
</div> </div>
+48 -41
View File
@@ -78,14 +78,19 @@ const columns = [
id: 'actions', id: 'actions',
header: '', header: '',
size: 100, size: 100,
cell: info => ( cell: info => {
<button const isExpired = info.row.original.days_to_expiry !== undefined && info.row.original.days_to_expiry < 0;
onClick={() => window.dispatchEvent(new CustomEvent('openAdjustModal', { detail: info.row.original }))} if (isExpired) return null;
className="text-blue-600 hover:text-blue-800 font-bold text-xs bg-blue-50 px-3 py-1 rounded"
> return (
Adjust <button
</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>
);
}
}), }),
]; ];
@@ -134,9 +139,19 @@ const Inventory = () => {
if (!selectedBatch || !adjustForm.qty_delta) return; if (!selectedBatch || !adjustForm.qty_delta) return;
try { try {
setIsAdjusting(true); setIsAdjusting(true);
let delta = parseInt(adjustForm.qty_delta);
const decreaseReasons = ['damaged', 'expired', 'lost'];
const increaseReasons = ['return'];
if (decreaseReasons.includes(adjustForm.reason_code)) {
delta = -Math.abs(delta);
} else if (increaseReasons.includes(adjustForm.reason_code)) {
delta = Math.abs(delta);
}
await adjustStock({ await adjustStock({
batch_no: selectedBatch.batch_no, batch_no: selectedBatch.batch_no,
qty_delta: parseInt(adjustForm.qty_delta), qty_delta: delta,
reason_code: adjustForm.reason_code, reason_code: adjustForm.reason_code,
notes: adjustForm.notes notes: adjustForm.notes
}); });
@@ -186,7 +201,7 @@ const Inventory = () => {
product_id: finalProductId, product_id: finalProductId,
expiry: formData.expiry, expiry: formData.expiry,
qty: formData.qty, qty: formData.qty,
purchase_price: formData.purchase_price purchase_price: formData.purchase_price || 0
} }
] ]
}; };
@@ -229,12 +244,20 @@ const Inventory = () => {
} }
if (data && data.batches) { if (data && data.batches) {
// If filterMode is expiring, we actually want near expiry // Calculate days_to_expiry manually in case API misses it
const processedBatches = data.batches.map((b: any) => {
if (b.days_to_expiry === undefined || b.days_to_expiry === null) {
b.days_to_expiry = Math.ceil((new Date(b.expiry).getTime() - Date.now()) / (1000 * 3600 * 24));
}
return b;
});
if (filterMode === 'expiring') { if (filterMode === 'expiring') {
const nearExpiry = data.batches.filter((b: any) => b.days_to_expiry && b.days_to_expiry <= 30); // Show medicines expiring in 90 days, INCLUDING expired (negative or zero)
const nearExpiry = processedBatches.filter((b: any) => b.days_to_expiry <= 90);
setInventory(nearExpiry); setInventory(nearExpiry);
} else { } else {
setInventory(data.batches); setInventory(processedBatches);
} }
} }
@@ -249,7 +272,7 @@ const Inventory = () => {
if (supps && supps.suppliers) { if (supps && supps.suppliers) {
setSupplierOptions(supps.suppliers); setSupplierOptions(supps.suppliers);
} }
} catch(e) { console.error("Could not fetch suppliers"); } } catch (e) { console.error("Could not fetch suppliers"); }
}); });
} catch (err: any) { } catch (err: any) {
@@ -366,7 +389,7 @@ const Inventory = () => {
{/* Action Bar with Spotlight Search */} {/* Action Bar with Spotlight Search */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}> <div className="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="relative w-full flex-1 max-w-none group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
@@ -376,7 +399,7 @@ const Inventory = () => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search batches by medicine or batch no..." placeholder="Search batches by medicine or batch no..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </div>
@@ -510,31 +533,31 @@ const Inventory = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Brand Name</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Brand Name</label>
<input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({...newMedicine, brand_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" /> <input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({ ...newMedicine, brand_name: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" />
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Generic Name</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Generic Name</label>
<input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({...newMedicine, generic_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" /> <input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({ ...newMedicine, generic_name: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" />
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Manufacturer</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Manufacturer</label>
<input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({...newMedicine, manufacturer: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" /> <input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({ ...newMedicine, manufacturer: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" />
</div> </div>
<div> <div>
<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> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">MRP (Price)</label> <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" /> <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>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Pack Size</label> <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" /> <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> <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' }}>
<option value="tablet">Tablet</option> <option value="tablet">Tablet</option>
<option value="capsule">Capsule</option> <option value="capsule">Capsule</option>
<option value="syrup">Syrup</option> <option value="syrup">Syrup</option>
@@ -543,7 +566,7 @@ const Inventory = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Schedule</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Schedule</label>
<select value={newMedicine.schedule} onChange={e => setNewMedicine({...newMedicine, schedule: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}> <select value={newMedicine.schedule} onChange={e => setNewMedicine({ ...newMedicine, schedule: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
<option value="H">Schedule H</option> <option value="H">Schedule H</option>
<option value="H1">Schedule H1</option> <option value="H1">Schedule H1</option>
<option value="X">Schedule X</option> <option value="X">Schedule X</option>
@@ -634,23 +657,7 @@ const Inventory = () => {
</div> </div>
</div> </div>
{/* Purchase Price */}
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Purchase Price</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-emerald-500 transition-colors">
<IndianRupee size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
placeholder="0.00"
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px' }}
/>
</div>
</div>
</div> </div>
<div className="border-t border-slate-100 bg-slate-50 flex justify-end shrink-0" style={{ padding: '20px 32px', gap: '16px' }}> <div className="border-t border-slate-100 bg-slate-50 flex justify-end shrink-0" style={{ padding: '20px 32px', gap: '16px' }}>
+9 -13
View File
@@ -110,36 +110,32 @@ export default function Patients() {
return ( 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="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="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<div className="w-full md:w-auto"> <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"> <h1 className="text-3xl font-bold text-slate-800">
Hospital Patient Directory Hospital Patient Directory
</h1> </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>
<div className="flex items-center gap-4 w-full md:w-auto"> <div className="flex items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-80 group"> <div className="relative w-full md:w-[450px] 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' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
setCurrentPage(1); // Reset page on search setCurrentPage(1);
}} }}
placeholder="Search by name, ID or contact..." 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]" 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-emerald-500/10 focus:border-emerald-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </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"> <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">
{filteredPatients.length} Records {filteredPatients.length} Patients
</div> </div>
</div> </div>
</div> </div>
+76 -35
View File
@@ -4,7 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
import { CustomSelect } from '../components/CustomSelect'; import { CustomSelect } from '../components/CustomSelect';
import { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder } from '../services/api'; import { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder, downloadInvoice, fetchSyncedPatients } from '../services/api';
interface PharmacyItem { interface PharmacyItem {
id: string; id: string;
@@ -103,10 +103,13 @@ const Pharmacy = () => {
const [precheckResult, setPrecheckResult] = useState<any>(null); const [precheckResult, setPrecheckResult] = useState<any>(null);
const [isProcessingDispense, setIsProcessingDispense] = useState(false); const [isProcessingDispense, setIsProcessingDispense] = useState(false);
const [dispensingId, setDispensingId] = useState<string | null>(null);
const resetDispenseModal = () => { const resetDispenseModal = () => {
setIsDispenseModalOpen(false); setIsDispenseModalOpen(false);
setDispenseStep(1); setDispenseStep(1);
setOrderDraftId(null); setOrderDraftId(null);
setDispensingId(null);
setPatientName(''); setPatientName('');
setSelectedItems([]); setSelectedItems([]);
setPrecheckResult(null); setPrecheckResult(null);
@@ -140,11 +143,12 @@ const Pharmacy = () => {
if (!orderDraftId) return; if (!orderDraftId) return;
try { try {
setIsProcessingDispense(true); setIsProcessingDispense(true);
await dispenseOrder(orderDraftId, { const res = await dispenseOrder(orderDraftId, {
subject_ref: patientName, subject_ref: patientName,
prescriber_name: prescriberName, prescriber_name: prescriberName,
payment_method: 'cash' payment_method: 'cash'
}); });
setDispensingId(res.dispensing_id);
setDispenseStep(3); setDispenseStep(3);
loadData(); // refresh inventory loadData(); // refresh inventory
} catch (e: any) { } catch (e: any) {
@@ -172,20 +176,28 @@ const Pharmacy = () => {
}; };
const mapAndSetData = (drugs: any[]) => { const mapAndSetData = (drugs: any[]) => {
const mappedData: PharmacyItem[] = drugs.map((drug: any) => ({ const mappedData: PharmacyItem[] = drugs.map((drug: any) => {
id: drug.product_id, const mrp = parseFloat(drug.mrp) || 0;
medicine: `${drug.brand_name} ${drug.strength || ''}`.trim(), const packSize = parseInt(drug.pack_size, 10) || 1;
subtitle: drug.generic_name || '', const unitPrice = mrp / packSize;
badge: drug.schedule || '',
stock: parseInt(drug.total_stock) || 0, return {
rawPrice: parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0, id: drug.product_id,
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0), medicine: `${drug.brand_name} ${drug.strength || ''}`.trim(),
manufacture: drug.manufacturer || 'Unknown', subtitle: drug.generic_name || '',
type: drug.form || 'Unknown' badge: drug.schedule || '',
})); stock: parseInt(drug.total_stock) || 0,
rawPrice: unitPrice,
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(unitPrice),
manufacture: drug.manufacturer || 'Unknown',
type: drug.form || 'Unknown'
};
});
setInventory(mappedData); setInventory(mappedData);
}; };
const [hospitalPatients, setHospitalPatients] = useState<any[]>([]);
const loadData = async () => { const loadData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -194,10 +206,17 @@ const Pharmacy = () => {
await authenticate(); await authenticate();
} }
const data = await getDrugs(); const [data, patients] = await Promise.all([
getDrugs(),
fetchSyncedPatients().catch(() => []) // fail gracefully if hospital api is down
]);
if (data && data.drugs) { if (data && data.drugs) {
mapAndSetData(data.drugs); mapAndSetData(data.drugs);
} }
if (patients) {
setHospitalPatients(patients);
}
} catch (error: any) { } catch (error: any) {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
try { try {
@@ -206,12 +225,14 @@ const Pharmacy = () => {
if (retryData && retryData.drugs) { if (retryData && retryData.drugs) {
mapAndSetData(retryData.drugs); mapAndSetData(retryData.drugs);
} }
const retryPatients = await fetchSyncedPatients().catch(() => []);
setHospitalPatients(retryPatients);
} catch (retryError) { } catch (retryError) {
console.error("Failed to fetch drugs after retry:", retryError); console.error("Failed to fetch data after retry:", retryError);
setError("Authentication failed. Please try logging in again."); setError("Authentication failed. Please try logging in again.");
} }
} else { } else {
console.error("Failed to fetch drugs:", error); console.error("Failed to fetch data:", error);
setError("Failed to connect to the server. Please try again later."); setError("Failed to connect to the server. Please try again later.");
} }
} finally { } finally {
@@ -221,6 +242,12 @@ const Pharmacy = () => {
useEffect(() => { useEffect(() => {
loadData(); loadData();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'dispense') {
setIsDispenseModalOpen(true);
// Clean up URL after opening
window.history.replaceState({}, '', '/pharmacy');
}
}, []); }, []);
// Filter inventory based on search query // Filter inventory based on search query
@@ -299,7 +326,7 @@ const Pharmacy = () => {
{/* Action Bar */} {/* Action Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
<div className="relative w-full max-w-2xl group"> <div className="relative w-full flex-1 max-w-none group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
@@ -309,19 +336,11 @@ const Pharmacy = () => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search medicines, OTC, or prescriptions..." placeholder="Search medicines, OTC, or prescriptions..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </div>
<div className="flex gap-2">
<button
onClick={() => setIsDispenseModalOpen(true)}
className="flex items-center gap-2 bg-emerald-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-emerald-700 transition-colors shadow-sm whitespace-nowrap"
>
<Package size={18} strokeWidth={2.5} />
Manual Dispense
</button>
</div>
</div> </div>
<div className="w-full flex flex-col bg-white border border-slate-200 rounded-2xl shadow-sm relative" style={{ marginTop: '16px' }}> <div className="w-full flex flex-col bg-white border border-slate-200 rounded-2xl shadow-sm relative" style={{ marginTop: '16px' }}>
@@ -471,15 +490,21 @@ const Pharmacy = () => {
<div> <div>
<div className="grid grid-cols-1 sm:grid-cols-2" style={{ gap: '16px', marginBottom: '16px' }}> <div className="grid grid-cols-1 sm:grid-cols-2" style={{ gap: '16px', marginBottom: '16px' }}>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name (from Hospital)</label>
<input <input
type="text" type="text"
value={patientName} value={patientName}
onChange={e => setPatientName(e.target.value)} onChange={e => setPatientName(e.target.value)}
list="patients-list"
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500"
style={{ padding: '12px 16px' }} style={{ padding: '12px 16px' }}
placeholder="e.g. John Doe" placeholder="e.g. John Doe"
/> />
<datalist id="patients-list">
{hospitalPatients.map((p, idx) => (
<option key={idx} value={p.full_name}>{p.patient_code}</option>
))}
</datalist>
</div> </div>
<div> <div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Prescriber Name</label> <label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Prescriber Name</label>
@@ -597,13 +622,29 @@ const Pharmacy = () => {
</div> </div>
<h3 className="font-bold text-emerald-800 text-xl" style={{ marginBottom: '8px' }}>Dispense Successful!</h3> <h3 className="font-bold text-emerald-800 text-xl" style={{ marginBottom: '8px' }}>Dispense Successful!</h3>
<p className="text-emerald-600" style={{ marginBottom: '24px' }}>Stock has been deducted and logged.</p> <p className="text-emerald-600" style={{ marginBottom: '24px' }}>Stock has been deducted and logged.</p>
<button <div className="flex justify-center gap-4">
onClick={resetDispenseModal} <button
className="bg-slate-800 text-white font-semibold rounded-xl hover:bg-slate-900 transition-colors" onClick={async () => {
style={{ padding: '10px 24px' }} try {
> await downloadInvoice(dispensingId || orderDraftId!);
Close } catch (e) {
</button> alert("Failed to download invoice");
console.error(e);
}
}}
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors flex items-center gap-2"
style={{ padding: '10px 24px' }}
>
Download Invoice
</button>
<button
onClick={resetDispenseModal}
className="bg-slate-800 text-white font-semibold rounded-xl hover:bg-slate-900 transition-colors"
style={{ padding: '10px 24px' }}
>
Close
</button>
</div>
</div> </div>
)} )}
</div> </div>
+20 -15
View File
@@ -340,17 +340,17 @@ const Prescriptions = () => {
); );
return ( return (
<div className="max-w-[1200px] mx-auto flex flex-col w-full p-8"> <div className="max-w-[1200px] mx-auto flex flex-col w-full px-8 py-10">
<header className="mb-8"> <header className="mb-10">
<h1>AI Prescription Processing</h1> <h1 className="text-3xl font-extrabold text-slate-900 tracking-tight">AI Prescription Processing</h1>
<p className="text-slate-500 mt-2">Upload a scanned prescription and let the AI extract the medical details.</p> <p className="text-slate-500 mt-2 text-lg">Upload a scanned prescription and let the AI extract the medical details.</p>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
{/* Upload Column */} {/* Upload Column */}
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6 h-fit"> <div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-8 h-fit">
<h2 className="mb-4 text-xl font-bold text-slate-800">Upload Scan</h2> <h2 className="mb-6 text-2xl font-bold text-slate-800">Upload Scan</h2>
<div className="border-2 border-dashed border-blue-500/30 rounded-2xl p-10 flex flex-col items-center justify-center text-center bg-blue-50 hover:bg-blue-100/50 transition-colors"> <div className="border-2 border-dashed border-blue-500/30 rounded-2xl p-10 flex flex-col items-center justify-center text-center bg-blue-50 hover:bg-blue-100/50 transition-colors">
<UploadCloud size={48} className="text-blue-400 mb-4" /> <UploadCloud size={48} className="text-blue-400 mb-4" />
@@ -374,18 +374,18 @@ const Prescriptions = () => {
</div> </div>
{file && ( {file && (
<div className="mt-6 flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl"> <div className="mt-6 flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<FileText className="text-blue-500" size={24} /> <FileText className="text-blue-500 flex-shrink-0" size={24} />
<div> <div className="min-w-0">
<p className="text-sm font-bold text-slate-800">{file.name}</p> <p className="text-sm font-bold text-slate-800 truncate">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p> <p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div> </div>
</div> </div>
{!isUploading && !isPolling && !prescriptionData && ( {!isUploading && !isPolling && !prescriptionData && (
<button <button
onClick={handleUpload} onClick={handleUpload}
className="bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-bold rounded-lg transition-colors shadow-sm" className="bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-bold rounded-lg transition-colors shadow-sm flex-shrink-0"
style={{ padding: '8px 16px' }} style={{ padding: '8px 16px' }}
> >
Process with AI Process with AI
@@ -512,9 +512,14 @@ const Prescriptions = () => {
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" 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> <option value="">-- Select Product --</option>
{drugOptions.map(d => ( {drugOptions.map(d => {
<option key={d.product_id} value={d.product_id}>{d.brand_name} ({d.generic_name})</option> const unitPrice = ((parseFloat(d.mrp) || 0) / (parseInt(d.pack_size, 10) || 1)).toFixed(2);
))} return (
<option key={d.product_id} value={d.product_id}>
{d.brand_name} ({d.generic_name}) - ₹{unitPrice}/unit
</option>
);
})}
</select> </select>
</div> </div>
+2 -2
View File
@@ -132,7 +132,7 @@ const Registers = () => {
</header> </header>
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}> <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="relative w-full flex-1 max-w-none group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
@@ -142,7 +142,7 @@ const Registers = () => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by patient, drug, or doctor..." placeholder="Search by patient, drug, or doctor..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </div>
+54 -14
View File
@@ -3,7 +3,7 @@ import { getAuditLogs, authenticate } from '../services/api';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Activity, ArrowDownRight, ArrowUpRight, Clock, Package, Search } from 'lucide-react'; import { Activity, ArrowDownRight, ArrowUpRight, Clock, Package, Search, AlertCircle } from 'lucide-react';
const columnHelper = createColumnHelper<any>(); const columnHelper = createColumnHelper<any>();
@@ -27,16 +27,28 @@ const columns = [
header: 'Movement Type', header: 'Movement Type',
size: 200, size: 200,
cell: info => { cell: info => {
const type = info.getValue(); const type = info.getValue() || '';
const isReceipt = type === 'receipt'; const isReceipt = type === 'receipt';
const isAdjustment = type === 'adjustment';
let bgClass = 'bg-pink-100 text-pink-700 border-pink-200';
let icon = <ArrowUpRight size={14} />;
let label = 'Dispensed';
if (isReceipt) {
bgClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
icon = <ArrowDownRight size={14} />;
label = 'Stock Received';
} else if (isAdjustment) {
bgClass = 'bg-amber-100 text-amber-700 border-amber-200';
icon = <AlertCircle size={14} />;
label = 'Adjustment';
}
return ( return (
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${ <div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold border ${bgClass}`}>
isReceipt {icon}
? 'bg-emerald-100 text-emerald-700 border border-emerald-200' {label}
: 'bg-pink-100 text-pink-700 border border-pink-200'
}`}>
{isReceipt ? <ArrowDownRight size={14} /> : <ArrowUpRight size={14} />}
{isReceipt ? 'Stock Received' : 'Dispensed'}
</div> </div>
); );
} }
@@ -63,6 +75,31 @@ const columns = [
</span> </span>
); );
} }
}),
columnHelper.accessor('notes', {
header: 'Details / Notes',
size: 250,
cell: info => {
const row = info.row.original;
const notes = row.notes;
const reasonCode = row.reason_code;
if (!notes && !reasonCode) return <span className="text-slate-400 text-sm italic">-</span>;
const safeReasonCode = reasonCode ? String(reasonCode).replace(/_/g, ' ') : '';
const safeNotes = notes ? String(notes) : '';
return (
<div className="flex flex-col gap-1">
{safeReasonCode && (
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded w-fit">
{safeReasonCode}
</span>
)}
{safeNotes && <span className="text-sm text-slate-700">{safeNotes}</span>}
</div>
);
}
}) })
]; ];
@@ -81,8 +118,11 @@ export default function StockMovements() {
} }
const logsData = await getAuditLogs(50); // fetch more logs for the table const logsData = await getAuditLogs(50); // fetch more logs for the table
console.log('logsData', logsData);
if (logsData && logsData.stock_movements) { if (logsData && logsData.stock_movements) {
setLogs(logsData.stock_movements); setLogs(logsData.stock_movements);
} else if (logsData && logsData.logs) {
setLogs(logsData.logs);
} }
} catch (err) { } catch (err) {
console.error('Failed to load stock movements:', err); console.error('Failed to load stock movements:', err);
@@ -129,17 +169,17 @@ export default function StockMovements() {
</header> </header>
<div className="flex items-center gap-4 w-full md:w-auto"> <div className="flex items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-80 group"> <div className="relative w-full md:w-[450px] group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none text-slate-400" style={{ paddingLeft: '16px' }}> <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={18} strokeWidth={2} /> <Search size={20} strokeWidth={2} />
</div> </div>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by batch no or type..." 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" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </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"> <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">
+2 -2
View File
@@ -177,7 +177,7 @@ const Suppliers = () => {
{/* Action Bar */} {/* Action Bar */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}> <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="relative w-full flex-1 max-w-none group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}> <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} /> <Search size={20} strokeWidth={2} />
</div> </div>
@@ -187,7 +187,7 @@ const Suppliers = () => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search suppliers by name, contact, DL or GSTIN..." 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" 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' }} style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/> />
</div> </div>
<div className="flex items-center gap-3 w-full md:w-auto"> <div className="flex items-center gap-3 w-full md:w-auto">
+94 -14
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { getMe, updateMe } from '../../services/api'; import { getMe, updateMe } from '../../services/api';
import { Save, User, CheckCircle, ArrowLeft, Building2, FileText, Hash, MapPin, Shield } from 'lucide-react'; import { Save, User, CheckCircle, ArrowLeft, Building2, FileText, Hash, MapPin, Shield, Camera, UploadCloud } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
const ProfileSettings = () => { const ProfileSettings = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState({ const [profile, setProfile] = useState({
username: '', username: '',
role: '', role: '',
@@ -12,11 +14,16 @@ const ProfileSettings = () => {
dl_number: '', dl_number: '',
gstin: '', gstin: '',
pharmacist_reg_number: '', pharmacist_reg_number: '',
address: '' address: '',
profile_image_url: ''
}); });
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
useEffect(() => { useEffect(() => {
@@ -31,7 +38,8 @@ const ProfileSettings = () => {
dl_number: data.profile.dl_number || '', dl_number: data.profile.dl_number || '',
gstin: data.profile.gstin || '', gstin: data.profile.gstin || '',
pharmacist_reg_number: data.profile.pharmacist_reg_number || '', pharmacist_reg_number: data.profile.pharmacist_reg_number || '',
address: data.profile.address || '' address: data.profile.address || '',
profile_image_url: data.profile.profile_image_url || ''
}); });
} }
} catch (err) { } catch (err) {
@@ -48,18 +56,45 @@ const ProfileSettings = () => {
setProfile(prev => ({ ...prev, [name]: value })); setProfile(prev => ({ ...prev, [name]: value }));
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setSelectedFile(file);
// Create local preview URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
};
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
setIsSaving(true); setIsSaving(true);
setMessage(''); setMessage('');
const data = await updateMe({
pharmacy_name: profile.pharmacy_name, const formData = new FormData();
dl_number: profile.dl_number, formData.append('pharmacy_name', profile.pharmacy_name);
gstin: profile.gstin, formData.append('dl_number', profile.dl_number);
pharmacist_reg_number: profile.pharmacist_reg_number, formData.append('gstin', profile.gstin);
address: profile.address formData.append('pharmacist_reg_number', profile.pharmacist_reg_number);
}); formData.append('address', profile.address);
if (profile.profile_image_url) {
formData.append('profile_image_url', profile.profile_image_url);
}
if (selectedFile) {
formData.append('profileImage', selectedFile);
}
const data = await updateMe(formData);
if (data && data.profile) {
setProfile(prev => ({
...prev,
profile_image_url: data.profile.profile_image_url || prev.profile_image_url
}));
}
setMessage('Profile updated successfully!'); setMessage('Profile updated successfully!');
setTimeout(() => setMessage(''), 3000); setTimeout(() => setMessage(''), 3000);
} catch (err) { } catch (err) {
@@ -113,6 +148,47 @@ const ProfileSettings = () => {
</div> </div>
<form onSubmit={handleSave} style={{ padding: '32px' }}> <form onSubmit={handleSave} style={{ padding: '32px' }}>
{/* Profile Image Upload Section */}
<div className="flex flex-col sm:flex-row items-center gap-6 mb-8 pb-8 border-b border-slate-100">
<div className="relative">
<div className="w-24 h-24 rounded-2xl bg-slate-100 border border-slate-200 overflow-hidden flex items-center justify-center">
{(previewUrl || profile.profile_image_url) ? (
<img src={previewUrl || profile.profile_image_url} alt="Profile" className="w-full h-full object-cover" />
) : (
<Camera size={32} className="text-slate-300" />
)}
</div>
{isUploading && (
<div className="absolute inset-0 bg-white/70 flex items-center justify-center rounded-2xl">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
<div>
<h3 className="font-bold text-slate-800 mb-1">Pharmacy Logo</h3>
<p className="text-sm text-slate-500 mb-3 max-w-sm">
Upload your pharmacy logo or profile picture. This will appear on invoices and reports.
</p>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isSaving}
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 hover:bg-slate-50 hover:border-slate-300 text-slate-700 font-bold rounded-lg transition-all text-sm shadow-sm"
>
<UploadCloud size={16} />
{selectedFile ? 'Change Image' : 'Select Image'}
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="col-span-1 md:col-span-2"> <div className="col-span-1 md:col-span-2">
@@ -229,10 +305,14 @@ const ProfileSettings = () => {
<button <button
type="submit" type="submit"
disabled={isSaving} 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'}`} className={`flex items-center justify-center gap-2 px-8 py-3.5 rounded-xl font-bold text-white transition-all duration-300 shadow-md whitespace-nowrap w-fit active:scale-95 ${
isSaving
? 'bg-slate-400 cursor-not-allowed shadow-none'
: 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 hover:shadow-lg hover:shadow-blue-500/30 hover:-translate-y-0.5'
}`}
> >
<Save size={18} /> <Save size={18} className={isSaving ? 'animate-pulse' : ''} />
{isSaving ? 'Saving...' : 'Save Changes'} <span>{isSaving ? 'Saving...' : 'Save Changes'}</span>
</button> </button>
</div> </div>
</form> </form>
+51 -6
View File
@@ -1,20 +1,25 @@
import axios from 'axios'; import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/v1';
// Create a base Axios instance for API calls // Create a base Axios instance for API calls
export const api = axios.create({ export const api = axios.create({
baseURL: 'http://localhost:8080/v1', // Backend port is 8080 baseURL: API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': import.meta.env.VITE_API_KEY || '',
}, },
}); });
// Authenticate and retrieve token // Authenticate and retrieve token
export const authenticate = async () => { export const authenticate = async () => {
try { try {
const response = await axios.post('http://localhost:8080/v1/auth/token', { const response = await axios.post(`${API_BASE_URL}/auth/token`, {
grant_type: 'client_credentials', grant_type: 'client_credentials',
client_id: 'jayam_web_ai', client_id: 'jayam_web_ai',
client_secret: 'my_secret_key' client_secret: 'my_secret_key'
}, {
headers: { 'x-api-key': import.meta.env.VITE_API_KEY || '' }
}); });
// Assuming the response contains an access_token field // Assuming the response contains an access_token field
@@ -33,7 +38,9 @@ export const authenticate = async () => {
// Login with username and password // Login with username and password
export const loginPharmacist = async (credentials: any) => { export const loginPharmacist = async (credentials: any) => {
try { try {
const response = await axios.post('http://localhost:8080/v1/auth/login', credentials); const response = await axios.post(`${API_BASE_URL}/auth/login`, credentials, {
headers: { 'x-api-key': import.meta.env.VITE_API_KEY || '' }
});
const data = response.data; const data = response.data;
if (data.token) { if (data.token) {
@@ -54,7 +61,9 @@ export const loginPharmacist = async (credentials: any) => {
export const logoutPharmacist = async () => { export const logoutPharmacist = async () => {
try { try {
// Call the logout endpoint // Call the logout endpoint
const response = await axios.post('http://localhost:8080/v1/auth/logout'); const response = await axios.post(`${API_BASE_URL}/auth/logout`, {}, {
headers: { 'x-api-key': import.meta.env.VITE_API_KEY || '' }
});
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Logout API failed:', error); console.error('Logout API failed:', error);
@@ -67,8 +76,11 @@ export const getMe = async () => {
return response.data; return response.data;
}; };
export const updateMe = async (data: any) => { export const updateMe = async (data: any | FormData) => {
const response = await api.put('/auth/me', data); const isFormData = data instanceof FormData;
const response = await api.put('/auth/me', data, {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
});
return response.data; return response.data;
}; };
@@ -101,6 +113,24 @@ api.interceptors.request.use((config) => {
return Promise.reject(error); return Promise.reject(error);
}); });
// Response interceptor to handle global errors like 401 Unauthorized
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// Do not redirect if we are already on the login page or trying to login
if (window.location.pathname !== '/login' && error.config && !error.config.url.includes('/auth/login')) {
// Token is invalid or expired
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
localStorage.removeItem('actor_id');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export const getDrugs = async () => { export const getDrugs = async () => {
const response = await api.get('/drugs'); const response = await api.get('/drugs');
return response.data; return response.data;
@@ -254,4 +284,19 @@ export const fetchSyncedPatients = async () => {
return response.data.data; return response.data.data;
}; };
export const downloadInvoice = async (id: string) => {
const response = await api.get(`/orders/${id}/invoice/pdf`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `invoice-${id}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
export default api; export default api;
+5 -1
View File
@@ -1,7 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
tailwindcss(),
],
}) })