Update frontend code
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/v1
|
||||
|
||||
VITE_API_KEY=915e46784e798f21759e52bd2a9d4cc2
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
Generated
+420
-90
File diff suppressed because it is too large
Load Diff
@@ -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.');
|
||||
@@ -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.');
|
||||
+6
-1
@@ -16,6 +16,8 @@ import Sidebar from './components/Sidebar';
|
||||
import { VoiceAssistant } from './components/VoiceAssistant';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
|
||||
import { Header } from './components/Header';
|
||||
|
||||
const ProtectedLayout = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
@@ -26,9 +28,12 @@ const ProtectedLayout = () => {
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
<Header />
|
||||
<main className="main-content flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<VoiceAssistant />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { logoutPharmacist } from '../services/api';
|
||||
|
||||
@@ -22,7 +22,7 @@ const Sidebar: React.FC = () => {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<HeartPulse size={28} color="#a5b4fc" />
|
||||
<Cross size={28} color="#a5b4fc" />
|
||||
<span>PharmaAI</span>
|
||||
</div>
|
||||
|
||||
@@ -99,16 +99,16 @@ const Sidebar: React.FC = () => {
|
||||
<span>Suppliers</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
{/* <NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<Settings className="nav-item-icon" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</NavLink> */}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer" style={{ marginTop: 'auto' }}>
|
||||
{/* <div className="sidebar-footer" style={{ marginTop: 'auto' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-item"
|
||||
@@ -117,7 +117,7 @@ const Sidebar: React.FC = () => {
|
||||
<LogOut className="nav-item-icon" />
|
||||
<span style={{ fontSize: '1rem' }}>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,8 +203,7 @@ export const VoiceAssistant: React.FC = () => {
|
||||
{/* Floating Action Button */}
|
||||
<button
|
||||
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 ${
|
||||
isListening
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
|
||||
@@ -205,3 +205,7 @@ h1 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
/* trigger update */
|
||||
/* trigger update 2 */
|
||||
/* trigger update dropdown */
|
||||
/* trigger update for button */
|
||||
|
||||
+33
-12
@@ -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 { authenticate, getAuditLogs, getDashboardMetrics } from '../services/api';
|
||||
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">
|
||||
|
||||
{/* 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="mb-6">
|
||||
<h2 className="text-xl font-bold text-slate-800">Stock Movements</h2>
|
||||
@@ -128,35 +128,56 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-xl font-bold text-slate-800">Recent Activity</h2>
|
||||
<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'}
|
||||
onClick={() => window.location.href = '/pharmacy?action=dispense'}
|
||||
>
|
||||
+ Quick Rx
|
||||
</button>
|
||||
</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) => {
|
||||
const isReceipt = log.movement_type === 'receipt';
|
||||
const isAdjustment = log.movement_type === 'adjustment';
|
||||
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 (
|
||||
<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 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={`flex-shrink-0 w-10 h-10 ml-2 mr-4 rounded-full flex items-center justify-center ${bgColor}`}>
|
||||
{icon}
|
||||
</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'}
|
||||
{title}
|
||||
</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 className="text-xs text-slate-500 mt-1">
|
||||
{actionText} <span className="font-medium text-slate-700">{actionValue}</span> (Batch: {log.batch_no})
|
||||
</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' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+26
-3
@@ -3,7 +3,7 @@ import { History as HistoryIcon, Clock, Package, AlertTriangle, Search } from 'l
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { getDispensingHistory } from '../services/api';
|
||||
import { getDispensingHistory, downloadInvoice } from '../services/api';
|
||||
|
||||
interface DispensingHistoryRecord {
|
||||
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 = () => {
|
||||
@@ -158,7 +181,7 @@ const History = () => {
|
||||
|
||||
{/* Action Bar with Spotlight Search */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl group">
|
||||
<div className="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' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -168,7 +191,7 @@ const History = () => {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by patient name, prescription ID or Dispensing ID..."
|
||||
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
|
||||
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+42
-35
@@ -78,14 +78,19 @@ const columns = [
|
||||
id: 'actions',
|
||||
header: '',
|
||||
size: 100,
|
||||
cell: info => (
|
||||
cell: info => {
|
||||
const isExpired = info.row.original.days_to_expiry !== undefined && info.row.original.days_to_expiry < 0;
|
||||
if (isExpired) return null;
|
||||
|
||||
return (
|
||||
<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;
|
||||
try {
|
||||
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({
|
||||
batch_no: selectedBatch.batch_no,
|
||||
qty_delta: parseInt(adjustForm.qty_delta),
|
||||
qty_delta: delta,
|
||||
reason_code: adjustForm.reason_code,
|
||||
notes: adjustForm.notes
|
||||
});
|
||||
@@ -186,7 +201,7 @@ const Inventory = () => {
|
||||
product_id: finalProductId,
|
||||
expiry: formData.expiry,
|
||||
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 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') {
|
||||
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);
|
||||
} else {
|
||||
setInventory(data.batches);
|
||||
setInventory(processedBatches);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +272,7 @@ const Inventory = () => {
|
||||
if (supps && supps.suppliers) {
|
||||
setSupplierOptions(supps.suppliers);
|
||||
}
|
||||
} catch(e) { console.error("Could not fetch suppliers"); }
|
||||
} catch (e) { console.error("Could not fetch suppliers"); }
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -366,7 +389,7 @@ const Inventory = () => {
|
||||
|
||||
{/* Action Bar with Spotlight Search */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl group">
|
||||
<div className="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' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -376,7 +399,7 @@ const Inventory = () => {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search batches by medicine or batch no..."
|
||||
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
|
||||
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -510,31 +533,31 @@ const Inventory = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Brand Name</label>
|
||||
<input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({...newMedicine, brand_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" />
|
||||
<input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({ ...newMedicine, brand_name: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Generic Name</label>
|
||||
<input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({...newMedicine, generic_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" />
|
||||
<input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({ ...newMedicine, generic_name: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Manufacturer</label>
|
||||
<input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({...newMedicine, manufacturer: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" />
|
||||
<input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({ ...newMedicine, manufacturer: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Strength</label>
|
||||
<input type="text" value={newMedicine.strength} onChange={e => setNewMedicine({...newMedicine, strength: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 500mg" />
|
||||
<input type="text" value={newMedicine.strength} onChange={e => setNewMedicine({ ...newMedicine, strength: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 500mg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">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>
|
||||
<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>
|
||||
<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="capsule">Capsule</option>
|
||||
<option value="syrup">Syrup</option>
|
||||
@@ -543,7 +566,7 @@ const Inventory = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Schedule</label>
|
||||
<select value={newMedicine.schedule} onChange={e => setNewMedicine({...newMedicine, schedule: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
|
||||
<select value={newMedicine.schedule} onChange={e => setNewMedicine({ ...newMedicine, schedule: e.target.value })} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
|
||||
<option value="H">Schedule H</option>
|
||||
<option value="H1">Schedule H1</option>
|
||||
<option value="X">Schedule X</option>
|
||||
@@ -634,23 +657,7 @@ const Inventory = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purchase Price */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Purchase Price</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-emerald-500 transition-colors">
|
||||
<IndianRupee size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.purchase_price}
|
||||
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
|
||||
style={{ padding: '12px 16px', paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 bg-slate-50 flex justify-end shrink-0" style={{ padding: '20px 32px', gap: '16px' }}>
|
||||
|
||||
+9
-13
@@ -110,36 +110,32 @@ export default function Patients() {
|
||||
|
||||
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="flex flex-col md:flex-row md:items-center justify-between mb-8 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">
|
||||
<h1 className="text-3xl font-bold text-slate-800">
|
||||
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="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' }}>
|
||||
<Search size={18} strokeWidth={2} />
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1); // Reset page on search
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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' }}
|
||||
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={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</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 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} Patients
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+62
-21
@@ -4,7 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { 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 {
|
||||
id: string;
|
||||
@@ -103,10 +103,13 @@ const Pharmacy = () => {
|
||||
const [precheckResult, setPrecheckResult] = useState<any>(null);
|
||||
const [isProcessingDispense, setIsProcessingDispense] = useState(false);
|
||||
|
||||
const [dispensingId, setDispensingId] = useState<string | null>(null);
|
||||
|
||||
const resetDispenseModal = () => {
|
||||
setIsDispenseModalOpen(false);
|
||||
setDispenseStep(1);
|
||||
setOrderDraftId(null);
|
||||
setDispensingId(null);
|
||||
setPatientName('');
|
||||
setSelectedItems([]);
|
||||
setPrecheckResult(null);
|
||||
@@ -140,11 +143,12 @@ const Pharmacy = () => {
|
||||
if (!orderDraftId) return;
|
||||
try {
|
||||
setIsProcessingDispense(true);
|
||||
await dispenseOrder(orderDraftId, {
|
||||
const res = await dispenseOrder(orderDraftId, {
|
||||
subject_ref: patientName,
|
||||
prescriber_name: prescriberName,
|
||||
payment_method: 'cash'
|
||||
});
|
||||
setDispensingId(res.dispensing_id);
|
||||
setDispenseStep(3);
|
||||
loadData(); // refresh inventory
|
||||
} catch (e: any) {
|
||||
@@ -172,20 +176,28 @@ const Pharmacy = () => {
|
||||
};
|
||||
|
||||
const mapAndSetData = (drugs: any[]) => {
|
||||
const mappedData: PharmacyItem[] = drugs.map((drug: any) => ({
|
||||
const mappedData: PharmacyItem[] = drugs.map((drug: any) => {
|
||||
const mrp = parseFloat(drug.mrp) || 0;
|
||||
const packSize = parseInt(drug.pack_size, 10) || 1;
|
||||
const unitPrice = mrp / packSize;
|
||||
|
||||
return {
|
||||
id: drug.product_id,
|
||||
medicine: `${drug.brand_name} ${drug.strength || ''}`.trim(),
|
||||
subtitle: drug.generic_name || '',
|
||||
badge: drug.schedule || '',
|
||||
stock: parseInt(drug.total_stock) || 0,
|
||||
rawPrice: parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0,
|
||||
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0),
|
||||
rawPrice: unitPrice,
|
||||
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(unitPrice),
|
||||
manufacture: drug.manufacturer || 'Unknown',
|
||||
type: drug.form || 'Unknown'
|
||||
}));
|
||||
};
|
||||
});
|
||||
setInventory(mappedData);
|
||||
};
|
||||
|
||||
const [hospitalPatients, setHospitalPatients] = useState<any[]>([]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -194,10 +206,17 @@ const Pharmacy = () => {
|
||||
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) {
|
||||
mapAndSetData(data.drugs);
|
||||
}
|
||||
if (patients) {
|
||||
setHospitalPatients(patients);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
try {
|
||||
@@ -206,12 +225,14 @@ const Pharmacy = () => {
|
||||
if (retryData && retryData.drugs) {
|
||||
mapAndSetData(retryData.drugs);
|
||||
}
|
||||
const retryPatients = await fetchSyncedPatients().catch(() => []);
|
||||
setHospitalPatients(retryPatients);
|
||||
} 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.");
|
||||
}
|
||||
} 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.");
|
||||
}
|
||||
} finally {
|
||||
@@ -221,6 +242,12 @@ const Pharmacy = () => {
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
@@ -299,7 +326,7 @@ const Pharmacy = () => {
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl group">
|
||||
<div className="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' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -309,19 +336,11 @@ const Pharmacy = () => {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search medicines, OTC, or prescriptions..."
|
||||
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
|
||||
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsDispenseModalOpen(true)}
|
||||
className="flex items-center gap-2 bg-emerald-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-emerald-700 transition-colors shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Package size={18} strokeWidth={2.5} />
|
||||
Manual Dispense
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2" style={{ gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name</label>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name (from Hospital)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
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"
|
||||
style={{ padding: '12px 16px' }}
|
||||
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>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Prescriber Name</label>
|
||||
@@ -597,6 +622,21 @@ const Pharmacy = () => {
|
||||
</div>
|
||||
<h3 className="font-bold text-emerald-800 text-xl" style={{ marginBottom: '8px' }}>Dispense Successful!</h3>
|
||||
<p className="text-emerald-600" style={{ marginBottom: '24px' }}>Stock has been deducted and logged.</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await downloadInvoice(dispensingId || orderDraftId!);
|
||||
} catch (e) {
|
||||
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"
|
||||
@@ -605,6 +645,7 @@ const Pharmacy = () => {
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+20
-15
@@ -340,17 +340,17 @@ const Prescriptions = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto flex flex-col w-full p-8">
|
||||
<header className="mb-8">
|
||||
<h1>AI Prescription Processing</h1>
|
||||
<p className="text-slate-500 mt-2">Upload a scanned prescription and let the AI extract the medical details.</p>
|
||||
<div className="max-w-[1200px] mx-auto flex flex-col w-full px-8 py-10">
|
||||
<header className="mb-10">
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 tracking-tight">AI Prescription Processing</h1>
|
||||
<p className="text-slate-500 mt-2 text-lg">Upload a scanned prescription and let the AI extract the medical details.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
|
||||
{/* Upload Column */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6 h-fit">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-800">Upload Scan</h2>
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-8 h-fit">
|
||||
<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">
|
||||
<UploadCloud size={48} className="text-blue-400 mb-4" />
|
||||
@@ -374,18 +374,18 @@ const Prescriptions = () => {
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="mt-6 flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-blue-500" size={24} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">{file.name}</p>
|
||||
<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 min-w-0">
|
||||
<FileText className="text-blue-500 flex-shrink-0" size={24} />
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{!isUploading && !isPolling && !prescriptionData && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-bold rounded-lg transition-colors shadow-sm"
|
||||
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' }}
|
||||
>
|
||||
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"
|
||||
>
|
||||
<option value="">-- Select Product --</option>
|
||||
{drugOptions.map(d => (
|
||||
<option key={d.product_id} value={d.product_id}>{d.brand_name} ({d.generic_name})</option>
|
||||
))}
|
||||
{drugOptions.map(d => {
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ const Registers = () => {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
|
||||
<div className="relative w-full max-w-2xl group">
|
||||
<div className="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' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ const Registers = () => {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by patient, drug, or doctor..."
|
||||
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
|
||||
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, Clock, Package, Search, AlertCircle } from 'lucide-react';
|
||||
|
||||
const columnHelper = createColumnHelper<any>();
|
||||
|
||||
@@ -27,16 +27,28 @@ const columns = [
|
||||
header: 'Movement Type',
|
||||
size: 200,
|
||||
cell: info => {
|
||||
const type = info.getValue();
|
||||
const type = info.getValue() || '';
|
||||
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 (
|
||||
<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 className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold border ${bgClass}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +75,31 @@ const columns = [
|
||||
</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
|
||||
console.log('logsData', logsData);
|
||||
if (logsData && logsData.stock_movements) {
|
||||
setLogs(logsData.stock_movements);
|
||||
} else if (logsData && logsData.logs) {
|
||||
setLogs(logsData.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load stock movements:', err);
|
||||
@@ -129,17 +169,17 @@ export default function StockMovements() {
|
||||
</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 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-blue-500" style={{ paddingLeft: '16px' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by 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' }}
|
||||
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={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -177,7 +177,7 @@ const Suppliers = () => {
|
||||
|
||||
{/* 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="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' }}>
|
||||
<Search size={20} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ const Suppliers = () => {
|
||||
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' }}
|
||||
style={{ height: '46px', paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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';
|
||||
|
||||
const ProfileSettings = () => {
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
username: '',
|
||||
role: '',
|
||||
@@ -12,11 +14,16 @@ const ProfileSettings = () => {
|
||||
dl_number: '',
|
||||
gstin: '',
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +38,8 @@ const ProfileSettings = () => {
|
||||
dl_number: data.profile.dl_number || '',
|
||||
gstin: data.profile.gstin || '',
|
||||
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) {
|
||||
@@ -48,18 +56,45 @@ const ProfileSettings = () => {
|
||||
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) => {
|
||||
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
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pharmacy_name', profile.pharmacy_name);
|
||||
formData.append('dl_number', profile.dl_number);
|
||||
formData.append('gstin', profile.gstin);
|
||||
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!');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (err) {
|
||||
@@ -113,6 +148,47 @@ const ProfileSettings = () => {
|
||||
</div>
|
||||
|
||||
<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="col-span-1 md:col-span-2">
|
||||
@@ -229,10 +305,14 @@ const ProfileSettings = () => {
|
||||
<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'}`}
|
||||
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} />
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
<Save size={18} className={isSaving ? 'animate-pulse' : ''} />
|
||||
<span>{isSaving ? 'Saving...' : 'Save Changes'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+51
-6
@@ -1,20 +1,25 @@
|
||||
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
|
||||
export const api = axios.create({
|
||||
baseURL: 'http://localhost:8080/v1', // Backend port is 8080
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': import.meta.env.VITE_API_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Authenticate and retrieve token
|
||||
export const authenticate = async () => {
|
||||
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',
|
||||
client_id: 'jayam_web_ai',
|
||||
client_secret: 'my_secret_key'
|
||||
}, {
|
||||
headers: { 'x-api-key': import.meta.env.VITE_API_KEY || '' }
|
||||
});
|
||||
|
||||
// Assuming the response contains an access_token field
|
||||
@@ -33,7 +38,9 @@ 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 response = await axios.post(`${API_BASE_URL}/auth/login`, credentials, {
|
||||
headers: { 'x-api-key': import.meta.env.VITE_API_KEY || '' }
|
||||
});
|
||||
const data = response.data;
|
||||
|
||||
if (data.token) {
|
||||
@@ -54,7 +61,9 @@ export const loginPharmacist = async (credentials: any) => {
|
||||
export const logoutPharmacist = async () => {
|
||||
try {
|
||||
// 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;
|
||||
} catch (error) {
|
||||
console.error('Logout API failed:', error);
|
||||
@@ -67,8 +76,11 @@ export const getMe = async () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateMe = async (data: any) => {
|
||||
const response = await api.put('/auth/me', data);
|
||||
export const updateMe = async (data: any | FormData) => {
|
||||
const isFormData = data instanceof FormData;
|
||||
const response = await api.put('/auth/me', data, {
|
||||
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -101,6 +113,24 @@ api.interceptors.request.use((config) => {
|
||||
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 () => {
|
||||
const response = await api.get('/drugs');
|
||||
return response.data;
|
||||
@@ -254,4 +284,19 @@ export const fetchSyncedPatients = async () => {
|
||||
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;
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user