-
Recent Activity
-
+ {/* Main Content Grid */}
+
+
+ {/* Chart Section */}
+
+
+
+
Stock Movements
+
Items received vs dispensed over the last 7 days
+
+
+
+ {metrics.trend.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
No trend data available for the past week.
+
+ )}
+
+
+
+ {/* Recent Activity Section */}
+
+
+
Recent Activity
+
+
+
+
{recentLogs.length > 0 ? recentLogs.map((log) => {
const isReceipt = log.movement_type === 'receipt';
const date = new Date(log.created_at);
return (
-
-
-
{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}
-
- {isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no})
+
+
+
+
+ {isReceipt ? 'Stock Replenished' : 'Medication Dispensed'}
+
+
+ {isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} units (Batch: {log.batch_no})
+
+
+ {date.toLocaleDateString()} • {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
-
{date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
);
}) : (
-
No recent activity.
+
+
+
All caught up!
+
No recent activity found.
+
)}
-
-
-
Quick Actions
-
-
-
-
-
-
);
diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx
index 2b82c68..363bf75 100644
--- a/src/pages/Inventory.tsx
+++ b/src/pages/Inventory.tsx
@@ -4,7 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { CustomSelect } from '../components/CustomSelect';
-import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine } from '../services/api';
+import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine, adjustStock } from '../services/api';
interface InventoryBatch {
batch_no: string;
@@ -74,6 +74,19 @@ const columns = [
);
}
}),
+ columnHelper.display({
+ id: 'actions',
+ header: '',
+ size: 100,
+ cell: info => (
+
+ )
+ }),
];
const Inventory = () => {
@@ -88,6 +101,7 @@ const Inventory = () => {
const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [drugOptions, setDrugOptions] = useState
([]);
+ const [supplierOptions, setSupplierOptions] = useState([]);
const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock');
const [newMedicine, setNewMedicine] = useState({
@@ -96,7 +110,9 @@ const Inventory = () => {
manufacturer: '',
form: 'tablet',
strength: '',
- schedule: 'H'
+ schedule: 'H',
+ mrp: '',
+ pack_size: '1'
});
const [formData, setFormData] = useState({
@@ -109,6 +125,32 @@ const Inventory = () => {
purchase_price: ''
});
+ const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false);
+ const [selectedBatch, setSelectedBatch] = useState(null);
+ const [adjustForm, setAdjustForm] = useState({ qty_delta: '', reason_code: 'damaged', notes: '' });
+ const [isAdjusting, setIsAdjusting] = useState(false);
+
+ const submitAdjustStock = async () => {
+ if (!selectedBatch || !adjustForm.qty_delta) return;
+ try {
+ setIsAdjusting(true);
+ await adjustStock({
+ batch_no: selectedBatch.batch_no,
+ qty_delta: parseInt(adjustForm.qty_delta),
+ reason_code: adjustForm.reason_code,
+ notes: adjustForm.notes
+ });
+ await loadData();
+ setIsAdjustModalOpen(false);
+ alert("Stock adjusted successfully!");
+ } catch (err) {
+ console.error(err);
+ alert("Failed to adjust stock.");
+ } finally {
+ setIsAdjusting(false);
+ }
+ };
+
const submitStock = async () => {
try {
setIsAddingStock(true);
@@ -123,7 +165,9 @@ const Inventory = () => {
manufacturer: newMedicine.manufacturer,
form: newMedicine.form,
strength: newMedicine.strength,
- schedule: newMedicine.schedule
+ schedule: newMedicine.schedule,
+ mrp: parseFloat(newMedicine.mrp) || 0,
+ pack_size: parseInt(newMedicine.pack_size, 10) || 1
};
const medRes = await addMedicine(medPayload);
if (medRes && medRes.product_id) {
@@ -155,7 +199,7 @@ const Inventory = () => {
// Reset state
setStockMode('restock');
- setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H' });
+ setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H', mrp: '', pack_size: '1' });
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
alert("Stock added successfully! Inventory count increased.");
@@ -198,6 +242,16 @@ const Inventory = () => {
if (drugsData && drugsData.drugs) {
setDrugOptions(drugsData.drugs);
}
+
+ import('../services/api').then(async (api) => {
+ try {
+ const supps = await api.getSuppliers();
+ if (supps && supps.suppliers) {
+ setSupplierOptions(supps.suppliers);
+ }
+ } catch(e) { console.error("Could not fetch suppliers"); }
+ });
+
} catch (err: any) {
if (err.response && err.response.status === 401) {
try {
@@ -229,8 +283,19 @@ const Inventory = () => {
setFilterMode(e.detail);
}
};
+ const handleOpenAdjust = (e: any) => {
+ if (e.detail) {
+ setSelectedBatch(e.detail);
+ setAdjustForm({ qty_delta: '', reason_code: 'damaged', notes: '' });
+ setIsAdjustModalOpen(true);
+ }
+ };
window.addEventListener('setFilter', handleSetFilter);
- return () => window.removeEventListener('setFilter', handleSetFilter);
+ window.addEventListener('openAdjustModal', handleOpenAdjust);
+ return () => {
+ window.removeEventListener('setFilter', handleSetFilter);
+ window.removeEventListener('openAdjustModal', handleOpenAdjust);
+ };
}, []);
// Filter based on search query
@@ -459,6 +524,14 @@ const Inventory = () => {
setNewMedicine({...newMedicine, strength: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 500mg" />
+
+
+ setNewMedicine({...newMedicine, mrp: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 15.50" />
+
+
+
+ setNewMedicine({...newMedicine, pack_size: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 10" />
+
>
)}
-
{/* GRN Details */}
-
-
+
+
+
@@ -541,7 +618,7 @@ const Inventory = () => {
{/* Quantity */}
-
+
@@ -550,7 +627,7 @@ const Inventory = () => {
type="number"
value={formData.qty}
onChange={(e) => setFormData({ ...formData, qty: e.target.value })}
- placeholder="Enter quantity"
+ placeholder="Enter quantity (e.g. 5 strips)"
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px' }}
/>
@@ -604,6 +681,94 @@ const Inventory = () => {
)}
+
+ {/* Adjust Stock Modal */}
+ {isAdjustModalOpen && selectedBatch && (
+
+
+
+
Adjust Stock
+
+
+
+
+
+
Batch Info
+
{selectedBatch.brand_name}
+
+ Batch: {selectedBatch.batch_no}
+ Current Qty: {selectedBatch.qty_on_hand}
+
+
+
+
+
+ setAdjustForm({ ...adjustForm, qty_delta: e.target.value })}
+ placeholder="e.g. -5 (for write-off), 10 (for found stock)"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px' }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
);
};
diff --git a/src/pages/Login.css b/src/pages/Login.css
new file mode 100644
index 0000000..92f717f
--- /dev/null
+++ b/src/pages/Login.css
@@ -0,0 +1,122 @@
+.login-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ width: 100vw;
+ background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+}
+
+.login-box {
+ background: white;
+ padding: 3rem;
+ border-radius: 12px;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
+ width: 100%;
+ max-width: 400px;
+}
+
+.login-header {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.login-header h2 {
+ color: #2c3e50;
+ margin-bottom: 0.5rem;
+ font-size: 1.8rem;
+}
+
+.login-header p {
+ color: #7f8c8d;
+ font-size: 0.95rem;
+}
+
+.login-error {
+ background-color: #fde8e8;
+ color: #e74c3c;
+ padding: 0.75rem;
+ border-radius: 6px;
+ margin-bottom: 1.5rem;
+ font-size: 0.875rem;
+ text-align: center;
+ border: 1px solid #fadbd8;
+}
+
+.login-form .form-group {
+ margin-bottom: 1.5rem;
+}
+
+.login-form label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #34495e;
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.login-form input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #bdc3c7;
+ border-radius: 6px;
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.login-form input:focus {
+ outline: none;
+ border-color: #3498db;
+ box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
+}
+
+.login-button {
+ width: 100%;
+ padding: 0.875rem;
+ background-color: #3498db;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ margin-top: 1rem;
+}
+
+.login-button:hover {
+ background-color: #2980b9;
+}
+
+.login-button:disabled {
+ background-color: #95a5a6;
+ cursor: not-allowed;
+}
+
+.password-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.password-input-wrapper input {
+ padding-right: 2.5rem;
+}
+
+.password-toggle-btn {
+ position: absolute;
+ right: 0.75rem;
+ background: none;
+ border: none;
+ color: #7f8c8d;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s;
+}
+
+.password-toggle-btn:hover {
+ color: #34495e;
+}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..c2ed25f
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,88 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Eye, EyeOff } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import { loginPharmacist } from '../services/api';
+import './Login.css';
+
+const Login = () => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+
+ const { login } = useAuth();
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setIsLoading(true);
+
+ try {
+ await loginPharmacist({ username, password });
+ login();
+ navigate('/dashboard');
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to login. Please check your credentials.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
AI Pharmacy
+
Sign in to your account
+
+
+ {error &&
{error}
}
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/src/pages/Patients.tsx b/src/pages/Patients.tsx
new file mode 100644
index 0000000..2d81589
--- /dev/null
+++ b/src/pages/Patients.tsx
@@ -0,0 +1,188 @@
+import { useEffect, useState, useMemo } from 'react';
+import { fetchSyncedPatients } from '../services/api';
+import { UserCircle, Mail, Phone, CalendarClock, ShieldCheck, Search } from 'lucide-react';
+import { createColumnHelper } from '@tanstack/react-table';
+import { Table } from '../components/Table';
+import Pagination from '../components/Pagination';
+
+const columnHelper = createColumnHelper
();
+
+const columns = [
+ columnHelper.accessor('full_name', {
+ header: 'Patient',
+ size: 350,
+ cell: info => {
+ const patient = info.row.original;
+ return (
+
+
+
+
+
+
+
{patient.full_name}
+
ID: {patient.hospital_patient_id}
+
+
+
+ );
+ }
+ }),
+ columnHelper.accessor('contact_number', {
+ header: 'Contact',
+ size: 200,
+ cell: info => (
+
+
+ {info.getValue() || 'N/A'}
+
+ )
+ }),
+ columnHelper.accessor('email', {
+ header: 'Email',
+ size: 250,
+ cell: info => (
+
+
+ {info.getValue() || 'N/A'}
+
+ )
+ }),
+ columnHelper.accessor('created_at', {
+ header: 'Synced At',
+ size: 200,
+ cell: info => (
+
+
+ {new Date(info.getValue()).toLocaleString()}
+
+ )
+ })
+];
+
+export default function Patients() {
+ const [patients, setPatients] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(10);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ useEffect(() => {
+ fetchSyncedPatients()
+ .then(data => {
+ setPatients(data);
+ setLoading(false);
+ })
+ .catch(err => {
+ console.error('Error fetching synced patients:', err);
+ setLoading(false);
+ });
+ }, []);
+
+ const filteredPatients = useMemo(() => {
+ const safePatients = Array.isArray(patients) ? patients : [];
+ if (!searchQuery.trim()) return safePatients;
+ const lowerQuery = searchQuery.toLowerCase();
+ return safePatients.filter(patient =>
+ patient && (
+ (patient.full_name && String(patient.full_name).toLowerCase().includes(lowerQuery)) ||
+ (patient.hospital_patient_id && String(patient.hospital_patient_id).toLowerCase().includes(lowerQuery)) ||
+ (patient.contact_number && String(patient.contact_number).toLowerCase().includes(lowerQuery)) ||
+ (patient.email && String(patient.email).toLowerCase().includes(lowerQuery))
+ )
+ );
+ }, [patients, searchQuery]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+
+
+ const totalItems = filteredPatients.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
+ const currentData = filteredPatients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
+
+ return (
+
+
+
+
+ Hospital Patient Directory
+
+
+
+ Securely Synced via AI Integration
+
+
+
+
+
+
+
+
+
{
+ setSearchQuery(e.target.value);
+ setCurrentPage(1); // Reset page on search
+ }}
+ placeholder="Search by name, ID or contact..."
+ className="w-full py-2.5 bg-white border border-slate-200 hover:border-slate-300 rounded-xl focus:outline-none focus:ring-4 focus:ring-emerald-500/10 focus:border-emerald-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800 text-[14px]"
+ style={{ paddingLeft: '44px', paddingRight: '16px' }}
+ />
+
+
+ {filteredPatients.length} Records
+
+
+
+
+
+
+ {(!Array.isArray(patients) || patients.length === 0) ? (
+
+
+
No Patients Found
+
No patients have been synced from the hospital yet.
+
+ ) : filteredPatients.length === 0 ? (
+
+
+
No Matches Found
+
No patients match your search "{searchQuery}".
+
+ ) : (
+
item.id}
+ emptyMessage="No patients found"
+ />
+ )}
+
+
+ {totalItems > 0 && (
+ setCurrentPage(page)}
+ onItemsPerPageChange={(limit) => {
+ setItemsPerPage(limit);
+ setCurrentPage(1);
+ }}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/pages/Pharmacy.tsx b/src/pages/Pharmacy.tsx
index d4ed64e..bb0d7e1 100644
--- a/src/pages/Pharmacy.tsx
+++ b/src/pages/Pharmacy.tsx
@@ -55,8 +55,8 @@ const columns = [
}
}),
columnHelper.accessor('price', {
- header: 'Price',
- size: 120,
+ header: 'Purchase Price / MRP',
+ size: 150,
cell: info => {info.getValue()}
}),
columnHelper.accessor('manufacture', {
@@ -89,6 +89,7 @@ const Pharmacy = () => {
form: 'tablet',
strength: '',
mrp: '',
+ pack_size: '1',
schedule: 'H'
});
@@ -160,7 +161,7 @@ const Pharmacy = () => {
setIsAdding(true);
await addMedicine(addFormData);
setIsAddModalOpen(false);
- setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', schedule: 'H' });
+ setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', pack_size: '1', schedule: 'H' });
await loadData();
} catch (err) {
console.error("Failed to add medicine", err);
@@ -177,8 +178,8 @@ const Pharmacy = () => {
subtitle: drug.generic_name || '',
badge: drug.schedule || '',
stock: parseInt(drug.total_stock) || 0,
- rawPrice: parseFloat(drug.mrp) || 0,
- price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.mrp) || 0),
+ rawPrice: parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0,
+ price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.purchase_price) || parseFloat(drug.mrp) || 0),
manufacture: drug.manufacturer || 'Unknown',
type: drug.form || 'Unknown'
}));
@@ -250,39 +251,48 @@ const Pharmacy = () => {
return (
-
-
-
Pharmacy Overview
+
+
-
+
Manage your pharmacy inventory, add medicines, and handle manual dispensing.
+
{/* Metrics Row */}
-
-
-
+
+
-
-
Total Medicines
-
{inventory.length}
+
+
Total Medicines
+
{inventory.length}
-
-
-
+
+
+ {lowStockCount > 0 && (
+
+ )}
+
-
-
Low Stock Alerts
-
{lowStockCount}
+
+
Low Stock Alerts
+
0 ? 'text-red-600' : 'text-slate-900'}`}>{lowStockCount}
-
-
-
+
+
+
+
-
-
Unique Categories
-
{uniqueCategories}
+
+
Unique Categories
+
{uniqueCategories}
@@ -311,14 +321,6 @@ const Pharmacy = () => {
Manual Dispense
-
-
@@ -422,6 +424,10 @@ const Pharmacy = () => {
setAddFormData({...addFormData, mrp: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="0.00" style={{ padding: '12px 16px' }} />
+
+
+ setAddFormData({...addFormData, pack_size: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500" placeholder="1" style={{ padding: '12px 16px' }} />
+
diff --git a/src/pages/Prescriptions.tsx b/src/pages/Prescriptions.tsx
index 4cbd4a9..8a230ab 100644
--- a/src/pages/Prescriptions.tsx
+++ b/src/pages/Prescriptions.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye } from 'lucide-react';
+import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye, User, Stethoscope, CalendarDays, Activity } from 'lucide-react';
import { uploadPrescription, getPrescriptionDetails, confirmPrescription, precheckOrder, dispenseOrder, getAllPrescriptions, getDrugs } from '../services/api';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
@@ -26,6 +26,7 @@ const Prescriptions = () => {
const [dispenseSuccess, setDispenseSuccess] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('cash');
const [drugOptions, setDrugOptions] = useState
([]);
+ const [mappedItems, setMappedItems] = useState>({});
// Queue State
const [prescriptionsList, setPrescriptionsList] = useState([]);
@@ -39,11 +40,39 @@ const Prescriptions = () => {
}).catch(console.error);
}, []);
+ useEffect(() => {
+ if (prescriptionData && drugOptions.length > 0) {
+ const items = prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || [];
+ const newMapping: Record = {};
+
+ items.forEach((m: any, idx: number) => {
+ const name = m.parsed?.brand || m.name || m.verbatim || '';
+ if (!name) return;
+
+ let matchedDrug = drugOptions.find(d =>
+ name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
+ d.brand_name.toLowerCase().includes(name.toLowerCase()) ||
+ (d.generic_name && (name.toLowerCase().includes(d.generic_name.toLowerCase()) || d.generic_name.toLowerCase().includes(name.toLowerCase())))
+ );
+
+ if (matchedDrug) {
+ newMapping[idx] = matchedDrug.product_id;
+ }
+ });
+ setMappedItems(newMapping);
+ }
+ }, [prescriptionData, drugOptions]);
+
const fetchPrescriptions = async () => {
try {
const data = await getAllPrescriptions();
if (data && data.prescriptions) {
- setPrescriptionsList(data.prescriptions);
+ // Tag digital ones based on the image_path inserted by the webhook
+ const tagged = data.prescriptions.map((p: any) => ({
+ ...p,
+ isDigital: p.image_path === 'E-Prescription (Digital Webhook)'
+ }));
+ setPrescriptionsList(tagged);
}
} catch (err) {
console.error("Failed to load queue", err);
@@ -131,14 +160,22 @@ const Prescriptions = () => {
size: 150,
cell: info => {
const status = info.getValue();
+ const isDigital = info.row.original.isDigital;
return (
-
- {status}
-
+
+ {isDigital && (
+
+ E-RX
+
+ )}
+
+ {status}
+
+
);
}
}),
@@ -214,18 +251,13 @@ const Prescriptions = () => {
allergies: [],
conditions: []
},
- items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => {
- const name = m.parsed?.brand || m.name || m.verbatim || '';
+ items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => {
+ let productId = mappedItems[idx];
+ if (!productId) {
+ throw new Error(`Product mapping missing for ${m.parsed?.brand || m.name || m.verbatim}`);
+ }
- let matchedDrug = drugOptions.find(d =>
- name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
- d.brand_name.toLowerCase().includes(name.toLowerCase())
- );
-
- let productId = matchedDrug ? matchedDrug.product_id : 'prd_4410'; // Default fallback if no match
-
-
- let qty = m.parsed?.qty || m.quantity;
+ let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity);
if (!qty || isNaN(qty)) {
const doseStr = m.parsed?.dose || m.dosage || '';
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
@@ -263,18 +295,13 @@ const Prescriptions = () => {
const payload = {
subject_ref: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown',
prescriber_name: prescriptionData.extracted_data.prescriber?.name || 'Unknown Prescriber',
- items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => {
- const name = m.parsed?.brand || m.name || m.verbatim || '';
+ items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any, idx: number) => {
+ let productId = mappedItems[idx];
+ if (!productId) {
+ throw new Error(`Product mapping missing for ${m.parsed?.brand || m.name || m.verbatim}`);
+ }
- let matchedDrug = drugOptions.find(d =>
- name.toLowerCase().includes(d.brand_name.toLowerCase()) ||
- d.brand_name.toLowerCase().includes(name.toLowerCase())
- );
-
- let productId = matchedDrug ? matchedDrug.product_id : 'prd_4410';
-
-
- let qty = m.parsed?.qty || m.quantity;
+ let qty = parseInt(m.parsed?.qty || m.parsed?.total_qty_str || m.quantity);
if (!qty || isNaN(qty)) {
// Try to extract qty from dosage like 1-0-1
const doseStr = m.parsed?.dose || m.dosage || '';
@@ -406,52 +433,107 @@ const Prescriptions = () => {
-
-
-
- setPrescriptionData({
- ...prescriptionData,
- extracted_data: {
- ...prescriptionData.extracted_data,
- patient: { ...prescriptionData.extracted_data?.patient, name: e.target.value },
- patient_name: e.target.value
- }
- })}
- className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
- />
-
-
-
-
-
setPrescriptionData({
- ...prescriptionData,
- extracted_data: {
- ...prescriptionData.extracted_data,
- prescriber: { ...prescriptionData.extracted_data?.prescriber, name: e.target.value },
- doctor_name: e.target.value
- }
- })}
- className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
- />
+
+
-
-
-
+
+
+
+
Prescribed Medications
+
+ {(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).length} Items
+
+
+
+
{(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).map((med: any, idx: number) => (
-
-
{med.parsed?.brand || med.name || med.verbatim}
-
{med.parsed?.dose || med.dosage}
+
+
+
+
{med.parsed?.brand || med.name || med.verbatim}
+
+ {med.parsed?.dose || med.dosage ? (
+
{med.parsed?.dose || med.dosage}
+ ) : null}
+ {med.parsed?.total_qty_str && (
+
Qty: {med.parsed.total_qty_str}
+ )}
+ {med.parsed?.duration && (
+
{med.parsed.duration}
+ )}
+
+
+
+
+
+
+
+
+
+
+ {med.parsed?.instruction && (
+
+
+
+ {med.parsed.instruction}
+
+
+ )}
+
))}
{!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && (
-
No medicines detected.
+
+
+
No medicines detected.
+
)}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
new file mode 100644
index 0000000..f676215
--- /dev/null
+++ b/src/pages/Settings.tsx
@@ -0,0 +1,63 @@
+import { User, Shield, ChevronRight } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+
+const Settings = () => {
+ const navigate = useNavigate();
+
+ const settingsCards = [
+ {
+ id: 'profile',
+ title: 'Edit Profile',
+ description: 'Manage your pharmacy profile, licensing details, and personal information.',
+ icon:
,
+ color: 'blue',
+ path: '/settings/profile'
+ },
+ {
+ id: 'security',
+ title: 'Change Password',
+ description: 'Update your account password and manage security preferences.',
+ icon:
,
+ color: 'emerald',
+ path: '/settings/security'
+ }
+ ];
+
+ return (
+
+
+
+
+ {settingsCards.map((card) => (
+
navigate(card.path)}
+ className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden hover:shadow-md hover:border-blue-300 transition-all cursor-pointer group"
+ >
+
+
+
+ {card.icon}
+
+
+
{card.title}
+
{card.description}
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default Settings;
diff --git a/src/pages/StockMovements.tsx b/src/pages/StockMovements.tsx
new file mode 100644
index 0000000..0e4e9a2
--- /dev/null
+++ b/src/pages/StockMovements.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useState, useMemo } from 'react';
+import { getAuditLogs, authenticate } from '../services/api';
+import { Table } from '../components/Table';
+import Pagination from '../components/Pagination';
+import { createColumnHelper } from '@tanstack/react-table';
+import { Activity, ArrowDownRight, ArrowUpRight, Clock, Package, Search } from 'lucide-react';
+
+const columnHelper = createColumnHelper
();
+
+const columns = [
+ columnHelper.accessor('created_at', {
+ header: 'Date & Time',
+ size: 200,
+ cell: info => {
+ const date = new Date(info.getValue());
+ return (
+
+ {date.toLocaleDateString()}
+
+ {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+ );
+ }
+ }),
+ columnHelper.accessor('movement_type', {
+ header: 'Movement Type',
+ size: 200,
+ cell: info => {
+ const type = info.getValue();
+ const isReceipt = type === 'receipt';
+ return (
+
+ {isReceipt ?
:
}
+ {isReceipt ? 'Stock Received' : 'Dispensed'}
+
+ );
+ }
+ }),
+ columnHelper.accessor('batch_no', {
+ header: 'Batch Number',
+ size: 200,
+ cell: info => (
+
+
+ {info.getValue() || 'N/A'}
+
+ )
+ }),
+ columnHelper.accessor('qty_delta', {
+ header: 'Quantity',
+ size: 150,
+ cell: info => {
+ const qty = info.getValue();
+ const isPositive = qty > 0;
+ return (
+
+ {isPositive ? '+' : ''}{qty}
+
+ );
+ }
+ })
+];
+
+export default function StockMovements() {
+ const [logs, setLogs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(10);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ if (!localStorage.getItem('auth_token')) {
+ await authenticate();
+ }
+
+ const logsData = await getAuditLogs(50); // fetch more logs for the table
+ if (logsData && logsData.stock_movements) {
+ setLogs(logsData.stock_movements);
+ }
+ } catch (err) {
+ console.error('Failed to load stock movements:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ const filteredLogs = useMemo(() => {
+ if (!searchQuery.trim()) return logs;
+ const lowerQuery = searchQuery.toLowerCase();
+ return logs.filter(log =>
+ (log.batch_no && log.batch_no.toLowerCase().includes(lowerQuery)) ||
+ (log.movement_type && log.movement_type.toLowerCase().includes(lowerQuery))
+ );
+ }, [logs, searchQuery]);
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchQuery]);
+
+ const totalItems = filteredLogs.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
+ const currentData = useMemo(() => {
+ return filteredLogs.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
+ }, [filteredLogs, currentPage, itemsPerPage]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="Search by batch no or type..."
+ className="w-full py-2.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm text-slate-800"
+ style={{ paddingLeft: '44px', paddingRight: '16px' }}
+ />
+
+
+ {filteredLogs.length} Records
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {totalItems > 0 && (
+
+
setCurrentPage(page)}
+ onItemsPerPageChange={(limit) => {
+ setItemsPerPage(limit);
+ setCurrentPage(1);
+ }}
+ />
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/Suppliers.tsx b/src/pages/Suppliers.tsx
new file mode 100644
index 0000000..1013b19
--- /dev/null
+++ b/src/pages/Suppliers.tsx
@@ -0,0 +1,424 @@
+import { useState, useEffect } from 'react';
+import { Search, Plus, AlertTriangle, X, Truck, User, Phone, Mail, FileText, MapPin } from 'lucide-react';
+import { createColumnHelper } from '@tanstack/react-table';
+import { Table } from '../components/Table';
+import Pagination from '../components/Pagination';
+import { getSuppliers, addSupplier, authenticate } from '../services/api';
+
+interface Supplier {
+ id: string;
+ name: string;
+ contact_number: string;
+ email: string;
+ gstin: string;
+ dl_number: string;
+ address: string;
+ contact_person: string;
+ created_at: string;
+}
+
+const columnHelper = createColumnHelper();
+
+const columns = [
+ columnHelper.accessor('name', {
+ header: 'COMPANY NAME',
+ size: 250,
+ cell: info => {info.getValue()}
+ }),
+ columnHelper.accessor('contact_person', {
+ header: 'CONTACT PERSON',
+ size: 180,
+ cell: info => {info.getValue() || '-'}
+ }),
+ columnHelper.accessor('contact_number', {
+ header: 'PHONE',
+ size: 150,
+ cell: info => {info.getValue() || '-'}
+ }),
+ columnHelper.accessor('email', {
+ header: 'EMAIL',
+ size: 200,
+ cell: info => {info.getValue() || '-'}
+ }),
+ columnHelper.accessor('dl_number', {
+ header: 'DL NUMBER',
+ size: 180,
+ cell: info => (
+
+ {info.getValue() || '-'}
+
+ )
+ }),
+ columnHelper.accessor('gstin', {
+ header: 'GSTIN',
+ size: 180,
+ cell: info => (
+
+ {info.getValue() || '-'}
+
+ )
+ }),
+ columnHelper.accessor('address', {
+ header: 'ADDRESS',
+ size: 300,
+ cell: info => {info.getValue() || '-'}
+ })
+];
+
+const Suppliers = () => {
+ const [currentPage, setCurrentPage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(25);
+ const [suppliers, setSuppliers] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAddingSupplier, setIsAddingSupplier] = useState(false);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const [formData, setFormData] = useState({
+ name: '',
+ contact_person: '',
+ contact_number: '',
+ email: '',
+ gstin: '',
+ dl_number: '',
+ address: ''
+ });
+
+ const loadData = async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ if (!localStorage.getItem('auth_token')) {
+ await authenticate();
+ }
+
+ const data = await getSuppliers();
+ if (data && data.suppliers) {
+ setSuppliers(data.suppliers);
+ }
+ } catch (err: any) {
+ if (err.response && err.response.status === 401) {
+ try {
+ await authenticate();
+ const retryData = await getSuppliers();
+ if (retryData && retryData.suppliers) {
+ setSuppliers(retryData.suppliers);
+ }
+ } catch (retryError) {
+ console.error("Failed to fetch suppliers after retry:", retryError);
+ setError("Authentication failed. Please try logging in again.");
+ }
+ } else {
+ console.error("Failed to fetch suppliers:", err);
+ setError("Failed to load suppliers data.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const submitSupplier = async () => {
+ if (!formData.name) {
+ alert("Company Name is required!");
+ return;
+ }
+
+ try {
+ setIsAddingSupplier(true);
+ await addSupplier(formData);
+
+ await loadData();
+ setIsModalOpen(false);
+ setFormData({
+ name: '',
+ contact_person: '',
+ contact_number: '',
+ email: '',
+ gstin: '',
+ dl_number: '',
+ address: ''
+ });
+ alert("Supplier added successfully!");
+ } catch (err) {
+ console.error("Failed to add supplier:", err);
+ alert("Failed to add supplier. Please try again.");
+ } finally {
+ setIsAddingSupplier(false);
+ }
+ };
+
+ const filteredSuppliers = suppliers.filter(s =>
+ s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (s.contact_person && s.contact_person.toLowerCase().includes(searchQuery.toLowerCase())) ||
+ (s.dl_number && s.dl_number.toLowerCase().includes(searchQuery.toLowerCase())) ||
+ (s.gstin && s.gstin.toLowerCase().includes(searchQuery.toLowerCase()))
+ );
+
+ const totalItems = filteredSuppliers.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
+ const currentData = filteredSuppliers.slice(
+ (currentPage - 1) * itemsPerPage,
+ currentPage * itemsPerPage
+ );
+
+ return (
+
+
+
+ {/* Action Bar */}
+
+
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="Search suppliers by name, contact, DL or GSTIN..."
+ className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
+ style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
+ />
+
+
+
+
+
+
+ {/* Table Card */}
+
+
+ {error ? (
+
+
+
{error}
+
+
+ ) : isLoading ? (
+
+
+
Loading suppliers...
+
+ ) : suppliers.length === 0 ? (
+
+
+
No Suppliers Found
+
You haven't added any wholesale distributors yet.
+
+
+ ) : (
+
item.id}
+ />
+ )}
+
+ {totalItems > 0 && !isLoading && !error && (
+ setCurrentPage(page)}
+ onItemsPerPageChange={(limit) => {
+ setItemsPerPage(limit);
+ setCurrentPage(1);
+ }}
+ />
+ )}
+
+
+ {/* Add Supplier Modal */}
+ {isModalOpen && (
+
+
+
+
+
+ Register New Supplier
+
+
+
+
+
+
+
+
+
+
+
+
+
setFormData({ ...formData, name: e.target.value })}
+ placeholder="e.g. Sri Ram Pharma Distributors"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
+
+
setFormData({ ...formData, contact_person: e.target.value })}
+ placeholder="e.g. Rahul Sharma"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
setFormData({ ...formData, contact_number: e.target.value })}
+ placeholder="10-digit number"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
+
+
setFormData({ ...formData, email: e.target.value })}
+ placeholder="contact@company.com"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
+
+
setFormData({ ...formData, gstin: e.target.value })}
+ placeholder="15-digit GST Number"
+ className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
+
+
setFormData({ ...formData, dl_number: e.target.value })}
+ placeholder="TN-xxx-xxxxx"
+ className="w-full bg-slate-50 border border-emerald-200/60 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-emerald-500/15 focus:border-emerald-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
+ style={{ padding: '12px 16px', paddingLeft: '44px' }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default Suppliers;
diff --git a/src/pages/settings/ProfileSettings.tsx b/src/pages/settings/ProfileSettings.tsx
new file mode 100644
index 0000000..54743c2
--- /dev/null
+++ b/src/pages/settings/ProfileSettings.tsx
@@ -0,0 +1,244 @@
+import { useState, useEffect } from 'react';
+import { getMe, updateMe } from '../../services/api';
+import { Save, User, CheckCircle, ArrowLeft, Building2, FileText, Hash, MapPin, Shield } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+
+const ProfileSettings = () => {
+ const navigate = useNavigate();
+ const [profile, setProfile] = useState({
+ username: '',
+ role: '',
+ pharmacy_name: '',
+ dl_number: '',
+ gstin: '',
+ pharmacist_reg_number: '',
+ address: ''
+ });
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ const fetchProfile = async () => {
+ try {
+ const data = await getMe();
+ if (data && data.profile) {
+ setProfile({
+ username: data.profile.username || '',
+ role: data.profile.role || '',
+ pharmacy_name: data.profile.pharmacy_name || '',
+ dl_number: data.profile.dl_number || '',
+ gstin: data.profile.gstin || '',
+ pharmacist_reg_number: data.profile.pharmacist_reg_number || '',
+ address: data.profile.address || ''
+ });
+ }
+ } catch (err) {
+ console.error("Failed to load profile", err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchProfile();
+ }, []);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setProfile(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ setIsSaving(true);
+ setMessage('');
+ const data = await updateMe({
+ pharmacy_name: profile.pharmacy_name,
+ dl_number: profile.dl_number,
+ gstin: profile.gstin,
+ pharmacist_reg_number: profile.pharmacist_reg_number,
+ address: profile.address
+ });
+ setMessage('Profile updated successfully!');
+ setTimeout(() => setMessage(''), 3000);
+ } catch (err) {
+ console.error("Failed to update profile", err);
+ setMessage('Failed to update profile.');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {message && (
+
+ {message.includes('successfully') && }
+ {message}
+
+ )}
+
+
+
+
+
+
+
+
Personal Information
+
Update your pharmacy licensing details below
+
+
+
+
+
+
+ );
+};
+
+export default ProfileSettings;
diff --git a/src/pages/settings/SecuritySettings.tsx b/src/pages/settings/SecuritySettings.tsx
new file mode 100644
index 0000000..df3d996
--- /dev/null
+++ b/src/pages/settings/SecuritySettings.tsx
@@ -0,0 +1,146 @@
+import { useState } from 'react';
+import { Shield, CheckCircle, ArrowLeft, Key } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+// import { updatePassword } from '../../services/api'; // Add this API later
+
+const SecuritySettings = () => {
+ const navigate = useNavigate();
+ const [passwords, setPasswords] = useState({
+ currentPassword: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+
+ const [isSaving, setIsSaving] = useState(false);
+ const [message, setMessage] = useState({ type: '', text: '' });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setPasswords(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (passwords.newPassword !== passwords.confirmPassword) {
+ setMessage({ type: 'error', text: 'New passwords do not match.' });
+ return;
+ }
+
+ try {
+ setIsSaving(true);
+ setMessage({ type: '', text: '' });
+ // const data = await updatePassword({
+ // current_password: passwords.currentPassword,
+ // new_password: passwords.newPassword
+ // });
+
+ // Simulating API call
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ setMessage({ type: 'success', text: 'Password changed successfully!' });
+ setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
+ setTimeout(() => setMessage({ type: '', text: '' }), 3000);
+ } catch (err) {
+ console.error("Failed to change password", err);
+ setMessage({ type: 'error', text: 'Failed to change password. Please check your current password.' });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ {message.text && (
+
+ {message.type === 'success' && }
+ {message.text}
+
+ )}
+
+
+
+
+
+
+
+
Change Password
+
Ensure your account is using a strong, unique password
+
+
+
+
+
+
+ );
+};
+
+export default SecuritySettings;
diff --git a/src/services/api.ts b/src/services/api.ts
index 2355f0a..b880a44 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -30,6 +30,48 @@ export const authenticate = async () => {
}
};
+// Login with username and password
+export const loginPharmacist = async (credentials: any) => {
+ try {
+ const response = await axios.post('http://localhost:8080/v1/auth/login', credentials);
+ const data = response.data;
+
+ if (data.token) {
+ localStorage.setItem('auth_token', data.token);
+ if (data.user && data.user.id) {
+ localStorage.setItem('actor_id', data.user.id);
+ localStorage.setItem('user', JSON.stringify(data.user));
+ }
+ }
+ return data;
+ } catch (error) {
+ console.error('Login failed:', error);
+ throw error;
+ }
+};
+
+// Logout pharmacist
+export const logoutPharmacist = async () => {
+ try {
+ // Call the logout endpoint
+ const response = await axios.post('http://localhost:8080/v1/auth/logout');
+ return response.data;
+ } catch (error) {
+ console.error('Logout API failed:', error);
+ throw error;
+ }
+};
+
+export const getMe = async () => {
+ const response = await api.get('/auth/me');
+ return response.data;
+};
+
+export const updateMe = async (data: any) => {
+ const response = await api.put('/auth/me', data);
+ return response.data;
+};
+
// Request interceptor to automatically add the auth token to headers
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
@@ -39,6 +81,19 @@ api.interceptors.request.use((config) => {
if (config.headers) {
config.headers['X-Actor-Id'] = localStorage.getItem('actor_id') || 'act_admin';
+
+ // Extract tenant_id from user object to ensure strict multi-tenancy
+ try {
+ const userStr = localStorage.getItem('user');
+ if (userStr) {
+ const user = JSON.parse(userStr);
+ if (user.tenant_id) {
+ config.headers['x-tenant-id'] = String(user.tenant_id);
+ } else if (user.hospital_id) {
+ config.headers['x-tenant-id'] = String(user.hospital_id);
+ }
+ }
+ } catch (e) {}
}
return config;
@@ -80,11 +135,36 @@ export const addStock = async (payload: any) => {
});
return response.data;
};
+
+export const adjustStock = async (payload: { batch_no: string, qty_delta: number, reason_code: string, notes?: string }) => {
+ const idempotencyKey = `idemp-adj-${Date.now()}-${Math.random().toString(36).substring(7)}`;
+ const response = await api.post('/inventory/adjustments', payload, {
+ headers: {
+ 'Idempotency-Key': idempotencyKey
+ }
+ });
+ return response.data;
+};
+
+export const getSuppliers = async () => {
+ const response = await api.get('/inventory/suppliers');
+ return response.data;
+};
+
+export const addSupplier = async (data: any) => {
+ const response = await api.post('/inventory/suppliers', data);
+ return response.data;
+};
export const getAuditLogs = async (limit: number = 10) => {
const response = await api.get(`/audit/ai-calls?limit=${limit}`);
return response.data;
};
+export const getDashboardMetrics = async () => {
+ const response = await api.get('/audit/dashboard-metrics');
+ return response.data;
+};
+
// --- AI Voice APIs ---
export const sendVoiceCommand = async (transcript: string) => {
const response = await api.post('/ai/voice', { transcript });
@@ -168,4 +248,10 @@ export const getH1Register = async () => {
return response.data;
};
+// --- Integration APIs ---
+export const fetchSyncedPatients = async () => {
+ const response = await api.get('/integration/hospital/patients');
+ return response.data.data;
+};
+
export default api;