feat: update frontend components and page layouts
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/v1
|
||||
|
||||
VITE_API_KEY=915e46784e798f21759e52bd2a9d4cc2
|
||||
VITE_API_KEY=DUMUqJrNeyuaPDSZSsMf91qjgwc5K3N7
|
||||
|
||||
Generated
+11
-11
@@ -65,6 +65,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -1409,7 +1419,6 @@
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -1420,7 +1429,6 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1567,7 +1575,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
@@ -2541,7 +2548,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2568,7 +2574,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2599,7 +2604,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2609,7 +2613,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -2629,7 +2632,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -2720,8 +2722,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -2928,7 +2929,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
|
||||
@@ -79,18 +79,18 @@ export const Header = () => {
|
||||
</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">
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-xl border border-slate-200 animate-in fade-in slide-in-from-top-2" style={{ padding: '8px' }}>
|
||||
<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"
|
||||
className="w-full flex items-center gap-3 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 rounded-lg transition-colors" style={{ padding: '10px 12px' }}
|
||||
>
|
||||
<Settings size={18} className="text-slate-400" />
|
||||
Settings
|
||||
</button>
|
||||
<div className="h-px bg-slate-100 my-3 mx-2"></div>
|
||||
<div className="h-px bg-slate-100" style={{ margin: '4px 0' }}></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"
|
||||
className="w-full flex items-center gap-3 text-sm font-medium text-rose-600 hover:bg-rose-50 hover:text-rose-700 rounded-lg transition-colors" style={{ padding: '10px 12px' }}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Logout
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search, ChevronDown, AlertTriangle, Check, X, Clock, Pill } from 'lucide-react';
|
||||
import { getExpiringInventory, getDrugs } from '../services/api';
|
||||
|
||||
export interface StockItem {
|
||||
id?: string;
|
||||
product_id?: string;
|
||||
batch_no?: string;
|
||||
brand_name?: string;
|
||||
generic_name?: string;
|
||||
qty_on_hand?: number;
|
||||
qty?: number;
|
||||
expiry?: string;
|
||||
days_to_expiry?: number;
|
||||
price?: string | number;
|
||||
mrp?: string | number;
|
||||
purchase_price?: string | number;
|
||||
manufacturer?: string;
|
||||
form?: string;
|
||||
schedule?: string;
|
||||
}
|
||||
|
||||
export interface StockDropdownProps {
|
||||
/** Selected value (product_id or batch_no depending on mode) */
|
||||
value?: string;
|
||||
/** Callback fired on selection change */
|
||||
onChange?: (value: string, selectedItem: StockItem | null) => void;
|
||||
/** Mode to display items: 'product' for drug catalog, 'batch' for inventory batch list */
|
||||
mode?: 'product' | 'batch';
|
||||
/** External list of options. If provided, disables internal auto-fetching unless autoFetch is true */
|
||||
options?: StockItem[];
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Label above the dropdown */
|
||||
label?: string;
|
||||
/** Error message string */
|
||||
error?: string;
|
||||
/** Disable the dropdown */
|
||||
disabled?: boolean;
|
||||
/** Optional custom class for outer wrapper */
|
||||
className?: string;
|
||||
/** Show stock level badges (In Stock, Low Stock, Out of Stock) */
|
||||
showStockBadge?: boolean;
|
||||
/** Show price / MRP in options */
|
||||
showPrice?: boolean;
|
||||
/** Automatically fetch data from backend if options not supplied */
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export function StockDropdown({
|
||||
value = '',
|
||||
onChange,
|
||||
mode = 'product',
|
||||
options: externalOptions,
|
||||
placeholder = 'Select stock item...',
|
||||
label,
|
||||
error,
|
||||
disabled = false,
|
||||
className = '',
|
||||
showStockBadge = true,
|
||||
showPrice = true,
|
||||
autoFetch = true
|
||||
}: StockDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [internalItems, setInternalItems] = useState<StockItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch data if external options are not provided
|
||||
useEffect(() => {
|
||||
if (externalOptions && externalOptions.length > 0) {
|
||||
setInternalItems(externalOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoFetch) return;
|
||||
|
||||
let isMounted = true;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (mode === 'batch') {
|
||||
const res = await getExpiringInventory();
|
||||
if (isMounted && res && res.batches) {
|
||||
setInternalItems(res.batches);
|
||||
}
|
||||
} else {
|
||||
const res = await getDrugs();
|
||||
if (isMounted && res && res.drugs) {
|
||||
setInternalItems(res.drugs);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('StockDropdown failed to fetch stock options:', err);
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [externalOptions, mode, autoFetch]);
|
||||
|
||||
// Use external or internal options
|
||||
const itemList = useMemo(() => {
|
||||
return externalOptions || internalItems;
|
||||
}, [externalOptions, internalItems]);
|
||||
|
||||
// Helper to get uniquely identifying key for each item
|
||||
const getItemKey = (item: StockItem): string => {
|
||||
if (mode === 'batch') {
|
||||
return item.batch_no || item.id || item.product_id || '';
|
||||
}
|
||||
return item.product_id || item.id || item.brand_name || '';
|
||||
};
|
||||
|
||||
// Currently selected item
|
||||
const selectedItem = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return itemList.find(item => getItemKey(item) === value) || null;
|
||||
}, [itemList, value, mode]);
|
||||
|
||||
// Filter items based on search input
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) return itemList;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return itemList.filter(item => {
|
||||
const brand = (item.brand_name || '').toLowerCase();
|
||||
const generic = (item.generic_name || '').toLowerCase();
|
||||
const batch = (item.batch_no || '').toLowerCase();
|
||||
const productId = (item.product_id || item.id || '').toLowerCase();
|
||||
const manufacturer = (item.manufacturer || '').toLowerCase();
|
||||
|
||||
return (
|
||||
brand.includes(q) ||
|
||||
generic.includes(q) ||
|
||||
batch.includes(q) ||
|
||||
productId.includes(q) ||
|
||||
manufacturer.includes(q)
|
||||
);
|
||||
});
|
||||
}, [itemList, searchQuery]);
|
||||
|
||||
// Handle outside click to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input on open
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (item: StockItem) => {
|
||||
const key = getItemKey(item);
|
||||
if (onChange) {
|
||||
onChange(key, item);
|
||||
}
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onChange) {
|
||||
onChange('', null);
|
||||
}
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const getStockBadge = (item: StockItem) => {
|
||||
const qty = item.qty_on_hand !== undefined ? item.qty_on_hand : item.qty;
|
||||
if (qty === undefined || qty === null) return null;
|
||||
|
||||
if (qty <= 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold bg-rose-100 text-rose-700 border border-rose-200">
|
||||
Out of Stock
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (qty < 50) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Low ({qty})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold bg-emerald-100 text-emerald-800 border border-emerald-200">
|
||||
In Stock ({qty})
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col w-full text-left ${className}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Main Select Trigger Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsOpen(prev => !prev)}
|
||||
className={`
|
||||
w-full flex items-center justify-between gap-3 px-4 py-3 rounded-xl border text-left transition-all duration-200 shadow-sm
|
||||
${disabled ? 'bg-slate-100 text-slate-400 cursor-not-allowed border-slate-200' : 'bg-slate-50 hover:bg-slate-100/80 border-slate-200 focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500'}
|
||||
${error ? 'border-rose-400 ring-2 ring-rose-500/10' : ''}
|
||||
`}
|
||||
style={{ minHeight: '48px' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="p-2 bg-blue-100/70 text-blue-600 rounded-lg flex-shrink-0">
|
||||
<Pill size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
|
||||
{selectedItem ? (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-900 text-[15px] truncate">
|
||||
{selectedItem.brand_name || selectedItem.generic_name || 'Selected Item'}
|
||||
</span>
|
||||
{mode === 'batch' && selectedItem.batch_no && (
|
||||
<span className="text-[11px] bg-slate-200/80 text-slate-700 font-mono px-2 py-0.5 rounded font-bold">
|
||||
{selectedItem.batch_no}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||
{selectedItem.generic_name && (
|
||||
<span className="truncate text-slate-500">{selectedItem.generic_name}</span>
|
||||
)}
|
||||
{showStockBadge && getStockBadge(selectedItem)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 text-sm font-medium truncate">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 text-slate-400">
|
||||
{selectedItem && !disabled && (
|
||||
<span
|
||||
onClick={handleClear}
|
||||
className="p-1 hover:bg-slate-200 rounded-full transition-colors text-slate-400 hover:text-slate-600 cursor-pointer"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={16} />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={2.2}
|
||||
className={`transition-transform duration-200 ${isOpen ? 'rotate-180 text-blue-600' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Popover Card */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 left-0 right-0 mt-2 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
|
||||
{/* Search Header Bar */}
|
||||
<div className="p-3.5 border-b border-slate-200/80 bg-slate-50/80 backdrop-blur-md">
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
color: '#94a3b8'
|
||||
}}
|
||||
>
|
||||
<Search size={18} strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search brand, generic name, or batch..."
|
||||
className="w-full bg-white border border-slate-200 hover:border-slate-300 rounded-xl text-sm font-medium text-slate-800 focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all"
|
||||
style={{
|
||||
paddingLeft: '44px',
|
||||
paddingRight: '40px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '10px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{searchQuery && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Item Options List */}
|
||||
<div className="max-h-72 overflow-y-auto p-2 space-y-1">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-slate-400 text-sm flex items-center justify-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-slate-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<span className="font-medium">Loading stock items...</span>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-400 text-sm font-medium">
|
||||
No stock items match "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map(item => {
|
||||
const key = getItemKey(item);
|
||||
const isSelected = key === value;
|
||||
const mrp = item.mrp || item.price || item.purchase_price;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => handleSelect(item)}
|
||||
className={`
|
||||
px-4 py-3 cursor-pointer rounded-xl transition-all duration-150 flex items-center justify-between gap-4 border
|
||||
${isSelected
|
||||
? 'bg-blue-50/90 border-blue-200/90 text-blue-950 shadow-sm'
|
||||
: 'border-transparent hover:bg-slate-100/70 hover:border-slate-200/60'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col min-w-0 flex-1 gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-900 text-[14px] leading-tight truncate">
|
||||
{item.brand_name || item.generic_name || 'Unnamed Item'}
|
||||
</span>
|
||||
{mode === 'batch' && item.batch_no && (
|
||||
<span className="text-[11px] bg-slate-100 text-slate-700 font-mono px-2 py-0.5 rounded font-bold border border-slate-200">
|
||||
{item.batch_no}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.generic_name && (
|
||||
<span className="text-xs text-slate-500 font-medium truncate">
|
||||
{item.generic_name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-xs text-slate-500">
|
||||
{showStockBadge && getStockBadge(item)}
|
||||
|
||||
{item.expiry && (
|
||||
<span className="flex items-center gap-1 text-[11px] font-medium text-slate-500 bg-slate-100 px-2 py-0.5 rounded-md border border-slate-200/60">
|
||||
<Clock size={12} className="text-slate-400" /> Exp: {new Date(item.expiry).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.form && (
|
||||
<span className="text-[11px] uppercase tracking-wider font-bold text-slate-400 bg-slate-100/80 px-1.5 py-0.5 rounded">
|
||||
{item.form}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{showPrice && mrp !== undefined && mrp !== null && (
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-400 block">MRP</span>
|
||||
<span className="font-extrabold text-slate-900 text-sm">
|
||||
₹{Number(mrp).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<div className="p-1 bg-blue-600 text-white rounded-full shadow-sm">
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Status Bar */}
|
||||
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex items-center justify-between text-xs text-slate-500 font-medium">
|
||||
<span className="font-semibold text-slate-600">{filteredItems.length} available items</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold uppercase tracking-wider">Mode: {mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-rose-500 flex items-center gap-1">
|
||||
<AlertTriangle size={12} /> {error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StockDropdown;
|
||||
@@ -8,7 +8,7 @@ import { MoreHorizontal } from 'lucide-react';
|
||||
export interface TableProps<T> {
|
||||
data: T[];
|
||||
columns: any[];
|
||||
getRowId?: (row: T) => string;
|
||||
getRowId?: (row: T, index?: number) => string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ export const Table = <T,>({ data, columns, getRowId, emptyMessage = 'No data ava
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
getRowId: (row: any, index: number) => {
|
||||
let customId = getRowId ? getRowId(row) : null;
|
||||
if (customId !== undefined && customId !== null && customId !== '') {
|
||||
return `${customId}_${index}`;
|
||||
}
|
||||
return row.id || row.batch_no || row.product_id || row.dispensing_id || `row-${index}`;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+12
-23
@@ -4,6 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { StockDropdown } from '../components/StockDropdown';
|
||||
import { getExpiringInventory, getLowStockInventory, authenticate, addStock, getDrugs, addMedicine, adjustStock } from '../services/api';
|
||||
|
||||
interface InventoryBatch {
|
||||
@@ -450,7 +451,7 @@ const Inventory = () => {
|
||||
<Table
|
||||
data={currentData}
|
||||
columns={columns}
|
||||
getRowId={(item) => item.batch_no}
|
||||
getRowId={(item, index) => item.batch_no ? `${item.batch_no}_${item.product_id || index}` : `inv-${index}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,28 +504,16 @@ const Inventory = () => {
|
||||
<div className="grid grid-cols-2 overflow-y-auto" style={{ padding: '24px', gap: '24px' }}>
|
||||
{stockMode === 'restock' ? (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Product ID</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
|
||||
<Package size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="product-options"
|
||||
value={formData.product_id}
|
||||
onChange={(e) => setFormData({ ...formData, product_id: e.target.value })}
|
||||
placeholder="Select or enter Product ID..."
|
||||
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' }}
|
||||
/>
|
||||
<datalist id="product-options">
|
||||
{drugOptions.map(d => (
|
||||
<option key={d.product_id} value={d.product_id}>
|
||||
{d.brand_name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<StockDropdown
|
||||
label="Product / Medicine (Search by Brand, Generic or Product ID)"
|
||||
placeholder="Search or select medicine to restock..."
|
||||
value={formData.product_id}
|
||||
options={drugOptions}
|
||||
mode="product"
|
||||
onChange={(val) => {
|
||||
setFormData({ ...formData, product_id: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
+30
-19
@@ -4,6 +4,7 @@ import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Table } from '../components/Table';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { StockDropdown } from '../components/StockDropdown';
|
||||
import { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder, downloadInvoice, fetchSyncedPatients } from '../services/api';
|
||||
|
||||
interface PharmacyItem {
|
||||
@@ -290,8 +291,8 @@ const Pharmacy = () => {
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0" style={{ marginBottom: '32px' }}>
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200/50 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5" style={{ padding: "24px" }}>
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
||||
<Package className="text-blue-600" size={28} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -304,7 +305,7 @@ const Pharmacy = () => {
|
||||
{lowStockCount > 0 && (
|
||||
<div className="absolute top-0 right-0 w-2 h-full bg-red-500"></div>
|
||||
)}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-red-50 to-red-100 border border-red-200/50 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-red-50 to-red-100 border border-red-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
||||
<AlertCircle className="text-red-600" size={28} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -313,8 +314,8 @@ const Pharmacy = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-indigo-50 to-indigo-100 border border-indigo-200/50 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex items-center gap-5" style={{ padding: "24px" }}>
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-indigo-50 to-indigo-100 border border-indigo-200/50 flex items-center justify-center shrink-0 shadow-sm" style={{ padding: "16px" }}>
|
||||
<Layers className="text-indigo-600" size={28} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -521,22 +522,32 @@ const Pharmacy = () => {
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Select Medicines (from inventory)</label>
|
||||
<CustomSelect
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const item = inventory.find(i => i.id === e.target.value);
|
||||
if (item && !selectedItems.find(i => i.product_id === item.id)) {
|
||||
setSelectedItems([...selectedItems, { product_id: item.id, name: item.medicine, qty: 1, price: item.rawPrice }]);
|
||||
<StockDropdown
|
||||
mode="product"
|
||||
placeholder="Search or select a medicine to add..."
|
||||
options={inventory.map(i => ({
|
||||
id: i.id,
|
||||
product_id: i.id,
|
||||
brand_name: i.medicine,
|
||||
generic_name: i.subtitle,
|
||||
qty_on_hand: i.stock,
|
||||
mrp: i.rawPrice,
|
||||
manufacturer: i.manufacture
|
||||
}))}
|
||||
onChange={(val, item) => {
|
||||
if (item && val && !selectedItems.find(i => i.product_id === val)) {
|
||||
setSelectedItems([
|
||||
...selectedItems,
|
||||
{
|
||||
product_id: item.product_id || item.id || val,
|
||||
name: item.brand_name || '',
|
||||
qty: 1,
|
||||
price: Number(item.mrp || 0)
|
||||
}
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>Select a medicine to add...</option>
|
||||
{inventory.filter(i => i.stock > 0).map(item => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.medicine} (Stock: {item.stock})
|
||||
</option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedItems.length > 0 && (
|
||||
|
||||
+20
-18
@@ -349,8 +349,8 @@ const Prescriptions = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
|
||||
{/* Upload Column */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-8 h-fit">
|
||||
<h2 className="mb-6 text-2xl font-bold text-slate-800">Upload Scan</h2>
|
||||
<div className="flex flex-col h-fit">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-800">Upload Scan</h2>
|
||||
|
||||
<div className="border-2 border-dashed border-blue-500/30 rounded-2xl p-10 flex flex-col items-center justify-center text-center bg-blue-50 hover:bg-blue-100/50 transition-colors">
|
||||
<UploadCloud size={48} className="text-blue-400 mb-4" />
|
||||
@@ -403,7 +403,7 @@ const Prescriptions = () => {
|
||||
</div>
|
||||
|
||||
{/* Results Column */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-800">Extraction Results</h2>
|
||||
|
||||
{(isUploading || isPolling) && !prescriptionData && (
|
||||
@@ -423,9 +423,10 @@ const Prescriptions = () => {
|
||||
)}
|
||||
|
||||
{prescriptionData && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500 bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden flex flex-col">
|
||||
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl flex items-start gap-3">
|
||||
<div className="px-6 py-4 bg-emerald-50 border-b border-emerald-100 flex items-start gap-3">
|
||||
<CheckCircle className="text-emerald-500 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-bold text-emerald-800">Extraction Complete</p>
|
||||
@@ -433,8 +434,8 @@ const Prescriptions = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="flex flex-col w-full px-6 py-6 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<User size={16} className="text-slate-400" /> Patient Name
|
||||
@@ -450,7 +451,7 @@ const Prescriptions = () => {
|
||||
patient_name: e.target.value
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-4 py-2.5 bg-white border border-slate-300 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -469,13 +470,13 @@ const Prescriptions = () => {
|
||||
doctor_name: e.target.value
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-4 py-2.5 bg-white border border-slate-300 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-100 bg-slate-50/50">
|
||||
<Pill className="text-blue-500" size={20} />
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Prescribed Medications</h3>
|
||||
<span className="ml-auto bg-slate-100 text-slate-600 py-1 px-3 rounded-full text-xs font-semibold">
|
||||
@@ -509,7 +510,7 @@ const Prescriptions = () => {
|
||||
<select
|
||||
value={mappedItems[idx] || ''}
|
||||
onChange={(e) => setMappedItems({...mappedItems, [idx]: e.target.value})}
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-md text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white border border-slate-300 rounded-md text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">-- Select Product --</option>
|
||||
{drugOptions.map(d => {
|
||||
@@ -544,7 +545,7 @@ const Prescriptions = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-100 flex justify-end">
|
||||
<div className="pt-4 flex justify-end border-t border-slate-100 mt-2">
|
||||
{isConfirmed ? (
|
||||
<div className="flex gap-4">
|
||||
<button disabled className="bg-emerald-100 text-emerald-700 font-bold rounded-xl flex items-center gap-2" style={{ padding: '10px 16px' }}>
|
||||
@@ -573,8 +574,9 @@ const Prescriptions = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{precheckResult && !dispenseSuccess && (
|
||||
</div>
|
||||
|
||||
{precheckResult && !dispenseSuccess && (
|
||||
<div className="mt-6 p-4 bg-slate-50 border border-slate-200 rounded-xl animate-in fade-in">
|
||||
<h3 className="font-bold text-slate-800 mb-2">Safety Pre-Check Results</h3>
|
||||
<div className="space-y-2 mb-4">
|
||||
@@ -602,7 +604,7 @@ const Prescriptions = () => {
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="upi">UPI / Scanner</option>
|
||||
@@ -631,7 +633,7 @@ const Prescriptions = () => {
|
||||
<p className="text-emerald-600 mt-1">Inventory has been deducted and audit logs updated.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
@@ -651,7 +653,7 @@ const Prescriptions = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto p-0">
|
||||
<div className="overflow-x-auto">
|
||||
{prescriptionsList.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
|
||||
Reference in New Issue
Block a user