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.');
|
||||||
+8
-3
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export const VoiceAssistant: React.FC = () => {
|
|||||||
const [aiResponse, setAiResponse] = useState('');
|
const [aiResponse, setAiResponse] = useState('');
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const recognitionRef = useRef<any>(null);
|
const recognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize Web Speech API
|
// Initialize Web Speech API
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
|
||||||
if (SpeechRecognition) {
|
if (SpeechRecognition) {
|
||||||
recognitionRef.current = new SpeechRecognition();
|
recognitionRef.current = new SpeechRecognition();
|
||||||
recognitionRef.current.continuous = false;
|
recognitionRef.current.continuous = false;
|
||||||
@@ -46,7 +46,7 @@ export const VoiceAssistant: React.FC = () => {
|
|||||||
interimTranscript += event.results[i][0].transcript;
|
interimTranscript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTranscript(finalTranscript || interimTranscript);
|
setTranscript(finalTranscript || interimTranscript);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,18 +76,18 @@ 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]);
|
||||||
|
|
||||||
// Real Intent Parser using Gemini AI backend
|
// Real Intent Parser using Gemini AI backend
|
||||||
const processCommand = async (command: string) => {
|
const processCommand = async (command: string) => {
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAiResponse("Thinking...");
|
setAiResponse("Thinking...");
|
||||||
const result = await sendVoiceCommand(command);
|
const result = await sendVoiceCommand(command);
|
||||||
|
|
||||||
if (result.replyText) {
|
if (result.replyText) {
|
||||||
speak(result.replyText);
|
speak(result.replyText);
|
||||||
setAiResponse(result.replyText);
|
setAiResponse(result.replyText);
|
||||||
@@ -147,7 +147,7 @@ export const VoiceAssistant: React.FC = () => {
|
|||||||
|
|
||||||
const toggleListening = () => {
|
const toggleListening = () => {
|
||||||
if (!recognitionRef.current) return;
|
if (!recognitionRef.current) return;
|
||||||
|
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
|
|
||||||
if (isListening) {
|
if (isListening) {
|
||||||
@@ -173,20 +173,20 @@ export const VoiceAssistant: React.FC = () => {
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 flex flex-col gap-4 min-h-[200px] max-h-[400px] overflow-y-auto">
|
<div className="p-5 flex flex-col gap-4 min-h-[200px] max-h-[400px] overflow-y-auto">
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<div className="self-end bg-slate-100 text-slate-800 p-3 rounded-2xl rounded-tr-none text-sm max-w-[85%] shadow-sm">
|
<div className="self-end bg-slate-100 text-slate-800 p-3 rounded-2xl rounded-tr-none text-sm max-w-[85%] shadow-sm">
|
||||||
{transcript}
|
{transcript}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{aiResponse && (
|
{aiResponse && (
|
||||||
<div className="self-start bg-indigo-50 text-indigo-900 p-3 rounded-2xl rounded-tl-none text-sm max-w-[85%] border border-indigo-100 whitespace-pre-line shadow-sm">
|
<div className="self-start bg-indigo-50 text-indigo-900 p-3 rounded-2xl rounded-tl-none text-sm max-w-[85%] border border-indigo-100 whitespace-pre-line shadow-sm">
|
||||||
{aiResponse}
|
{aiResponse}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isListening && !transcript && (
|
{isListening && !transcript && (
|
||||||
<div className="text-center text-slate-400 text-sm italic mt-4 flex items-center justify-center gap-2">
|
<div className="text-center text-slate-400 text-sm italic mt-4 flex items-center justify-center gap-2">
|
||||||
<span className="relative flex h-3 w-3">
|
<span className="relative flex h-3 w-3">
|
||||||
@@ -203,18 +203,17 @@ 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" />
|
||||||
) : (
|
) : (
|
||||||
<Mic className="text-white w-7 h-7" />
|
<Mic className="text-white w-7 h-7" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Glowing ring animation when listening */}
|
{/* Glowing ring animation when listening */}
|
||||||
{isListening && (
|
{isListening && (
|
||||||
<span className="absolute -inset-2 rounded-full border-2 border-rose-400 animate-ping opacity-50"></span>
|
<span className="absolute -inset-2 rounded-full border-2 border-rose-400 animate-ping opacity-50"></span>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
+55
-48
@@ -54,7 +54,7 @@ const columns = [
|
|||||||
const days = info.getValue();
|
const days = info.getValue();
|
||||||
let colorClass = "text-emerald-600 bg-emerald-50";
|
let colorClass = "text-emerald-600 bg-emerald-50";
|
||||||
let displayText = `${days} Days`;
|
let displayText = `${days} Days`;
|
||||||
|
|
||||||
if (days < 0) {
|
if (days < 0) {
|
||||||
colorClass = "text-rose-700 bg-rose-100 border border-rose-200";
|
colorClass = "text-rose-700 bg-rose-100 border border-rose-200";
|
||||||
displayText = "Expired";
|
displayText = "Expired";
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
@@ -155,7 +170,7 @@ const Inventory = () => {
|
|||||||
try {
|
try {
|
||||||
setIsAddingStock(true);
|
setIsAddingStock(true);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
let finalProductId = formData.product_id;
|
let finalProductId = formData.product_id;
|
||||||
|
|
||||||
if (stockMode === 'new') {
|
if (stockMode === 'new') {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -196,12 +211,12 @@ const Inventory = () => {
|
|||||||
// Refresh the inventory data after successful addition
|
// Refresh the inventory data after successful addition
|
||||||
await loadData();
|
await loadData();
|
||||||
setIsModalOpen(false); // close the modal on success
|
setIsModalOpen(false); // close the modal on success
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
setStockMode('restock');
|
setStockMode('restock');
|
||||||
setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H', mrp: '', pack_size: '1' });
|
setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H', mrp: '', pack_size: '1' });
|
||||||
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
|
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
|
||||||
|
|
||||||
alert("Stock added successfully! Inventory count increased.");
|
alert("Stock added successfully! Inventory count increased.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add stock:", err);
|
console.error("Failed to add stock:", err);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +265,14 @@ const Inventory = () => {
|
|||||||
if (drugsData && drugsData.drugs) {
|
if (drugsData && drugsData.drugs) {
|
||||||
setDrugOptions(drugsData.drugs);
|
setDrugOptions(drugsData.drugs);
|
||||||
}
|
}
|
||||||
|
|
||||||
import('../services/api').then(async (api) => {
|
import('../services/api').then(async (api) => {
|
||||||
try {
|
try {
|
||||||
const supps = await api.getSuppliers();
|
const supps = await api.getSuppliers();
|
||||||
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>
|
||||||
@@ -463,13 +486,13 @@ const Inventory = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex border-b border-slate-200 bg-slate-50/50 px-6 shrink-0">
|
<div className="flex border-b border-slate-200 bg-slate-50/50 px-6 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStockMode('restock')}
|
onClick={() => setStockMode('restock')}
|
||||||
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'restock' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'restock' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
||||||
>
|
>
|
||||||
Restock Existing
|
Restock Existing
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStockMode('new')}
|
onClick={() => setStockMode('new')}
|
||||||
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'new' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-colors ${stockMode === 'new' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}
|
||||||
>
|
>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user