first commit .

This commit is contained in:
2026-07-29 11:47:37 +05:30
parent c0d7302718
commit bd800a3bab
32 changed files with 6360 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ai_pharma_frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2675
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "ai_pharma_frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"axios": "^1.18.1",
"lucide-react": "^1.26.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.4",
"oxlint": "^1.71.0",
"postcss": "^8.5.23",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import Pharmacy from './pages/Pharmacy';
import Inventory from './pages/Inventory';
import Prescriptions from './pages/Prescriptions';
import History from './pages/History';
import Registers from './pages/Registers';
import Sidebar from './components/Sidebar';
import { VoiceAssistant } from './components/VoiceAssistant';
function App() {
return (
<Router>
<div className="app-container">
<Sidebar />
<main className="main-content">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/pharmacy" element={<Pharmacy />} />
<Route path="/patients" element={<div>Patients View</div>} />
<Route path="/history" element={<History />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/prescriptions" element={<Prescriptions />} />
<Route path="/registers" element={<Registers />} />
<Route path="/settings" element={<div>Settings View</div>} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
{/* Global AI Voice Assistant */}
<VoiceAssistant />
</div>
</Router>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+43
View File
@@ -0,0 +1,43 @@
import React, { type SelectHTMLAttributes, type ReactNode } from 'react';
import { ChevronDown } from 'lucide-react';
interface CustomSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
icon?: ReactNode;
}
export function CustomSelect({ icon, children, style, className = '', ...props }: CustomSelectProps) {
return (
<div style={{ position: 'relative', width: '100%' }}>
{icon && (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, paddingLeft: '16px', display: 'flex', alignItems: 'center', pointerEvents: 'none', color: '#94a3b8', zIndex: 10 }}>
{icon}
</div>
)}
<select
style={{
width: '100%',
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '0.75rem',
padding: '12px 16px',
paddingRight: '40px',
paddingLeft: icon ? '44px' : '16px',
fontSize: '15px',
fontWeight: 500,
color: '#1e293b',
appearance: 'none',
WebkitAppearance: 'none',
cursor: 'pointer',
outline: 'none',
...style
}}
{...props}
>
{children}
</select>
<div style={{ position: 'absolute', top: '50%', right: '16px', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#94a3b8', display: 'flex', alignItems: 'center' }}>
<ChevronDown size={18} strokeWidth={2} />
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
export interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems?: number;
itemsPerPage?: number;
itemsPerPageOptions?: number[];
onPageChange: (page: number) => void;
onItemsPerPageChange?: (itemsPerPage: number) => void;
}
const Pagination = ({
currentPage,
totalPages,
totalItems,
itemsPerPage,
itemsPerPageOptions,
onPageChange,
onItemsPerPageChange
}: PaginationProps) => {
return (
<div className="py-4 flex flex-col md:flex-row items-center justify-between w-full gap-4 md:gap-0 bg-white border-t border-slate-200 rounded-b-2xl" style={{ paddingLeft: '24px', paddingRight: '24px' }}>
<div className="text-sm text-slate-500">
Showing <span className="font-semibold text-slate-700">{(currentPage - 1) * (itemsPerPage || 10) + 1}</span>-
<span className="font-semibold text-slate-700">{Math.min(currentPage * (itemsPerPage || 10), totalItems || 0)}</span> of <span className="font-semibold text-slate-700">{totalItems}</span>
{onItemsPerPageChange && (
<span className="ml-4 inline-flex items-center gap-2">
<span>Rows per page:</span>
<select
value={itemsPerPage || 10}
onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
className="border-0 bg-slate-50 rounded-md px-2 py-1 text-slate-700 font-medium outline-none cursor-pointer hover:bg-slate-100 transition-colors focus:ring-2 focus:ring-blue-100"
>
{(itemsPerPageOptions || [5, 10, 20, 50]).map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
className="p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronsLeft size={18} />
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={18} />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum = i + 1;
if (totalPages > 5 && currentPage > 3) {
pageNum = currentPage - 2 + i;
if (pageNum > totalPages) pageNum = totalPages - (4 - i);
}
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`w-8 h-8 flex items-center justify-center rounded-md text-[13px] font-bold transition-all ${
currentPage === pageNum
? 'bg-blue-600 text-white shadow-sm shadow-blue-200'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={18} />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
className="p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronsRight size={18} />
</button>
</div>
</div>
);
};
export default Pagination;
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Users, Pill, Settings, HeartPulse, Box, Clock, ClipboardList } from 'lucide-react';
const Sidebar: React.FC = () => {
return (
<aside className="sidebar">
<div className="sidebar-logo">
<HeartPulse size={28} color="#a5b4fc" />
<span>PharmaAI</span>
</div>
<nav className="sidebar-nav">
<NavLink
to="/"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<LayoutDashboard className="nav-item-icon" />
<span>Dashboard</span>
</NavLink>
<NavLink
to="/pharmacy"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Pill className="nav-item-icon" />
<span>Pharmacy</span>
</NavLink>
<NavLink
to="/prescriptions"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<ClipboardList className="nav-item-icon" />
<span>Prescriptions</span>
</NavLink>
{/*
<NavLink
to="/patients"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Users className="nav-item-icon" />
<span>Patients</span>
</NavLink>
*/}
<NavLink
to="/registers"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<ClipboardList className="nav-item-icon" />
<span>Registers</span>
</NavLink>
<NavLink
to="/history"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Clock className="nav-item-icon" />
<span>History</span>
</NavLink>
<NavLink
to="/inventory"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Box className="nav-item-icon" />
<span>Inventory</span>
</NavLink>
</nav>
<div className="sidebar-footer" style={{ marginTop: 'auto' }}>
<NavLink
to="/settings"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<Settings className="nav-item-icon" />
<span>Settings</span>
</NavLink>
</div>
</aside>
);
};
export default Sidebar;
+81
View File
@@ -0,0 +1,81 @@
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
export interface TableProps<T> {
data: T[];
columns: any[];
getRowId?: (row: T) => string;
emptyMessage?: string;
}
export const Table = <T,>({ data, columns, getRowId, emptyMessage = 'No data available' }: TableProps<T>) => {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId,
});
return (
<div className="flex flex-col w-full">
<div className="overflow-x-auto overflow-y-auto">
<table className="w-full text-left border-collapse min-w-[800px] table-auto">
<thead className="bg-slate-50 border-b border-slate-200/80">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className="py-4 px-6 text-[11px] font-bold text-slate-500 uppercase tracking-widest whitespace-nowrap"
style={{ padding: '16px 24px' }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="py-16 text-center text-slate-500" style={{ padding: '64px 24px' }}>
<div className="flex flex-col items-center justify-center opacity-70">
<MoreHorizontal className="w-8 h-8 text-slate-400 mb-3" />
<p className="text-[14px] font-medium">{emptyMessage}</p>
</div>
</td>
</tr>
) : (
table.getRowModel().rows.map(row => (
<tr
key={row.id}
className="group hover:bg-slate-50 transition-all duration-200 ease-in-out cursor-default"
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="py-4 px-6 text-[14px] text-slate-700 whitespace-nowrap group-hover:text-slate-900 transition-colors"
style={{ padding: '16px 24px' }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
+215
View File
@@ -0,0 +1,215 @@
import { useState, useEffect, useRef } from 'react';
import { Mic, MicOff, X, Activity } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { sendVoiceCommand } from '../services/api';
// Declare standard Window interface extensions for Web Speech API
declare global {
interface Window {
SpeechRecognition: any;
webkitSpeechRecognition: any;
}
}
export const VoiceAssistant: React.FC = () => {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState('');
const [aiResponse, setAiResponse] = useState('');
const [isPanelOpen, setIsPanelOpen] = useState(false);
const navigate = useNavigate();
const recognitionRef = useRef<any>(null);
useEffect(() => {
// Initialize Web Speech API
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
recognitionRef.current = new SpeechRecognition();
recognitionRef.current.continuous = false;
recognitionRef.current.interimResults = true;
recognitionRef.current.lang = 'en-US'; // Default to English for now, can be changed later
recognitionRef.current.onstart = () => {
setIsListening(true);
setAiResponse('');
};
recognitionRef.current.onresult = (event: any) => {
let interimTranscript = '';
let finalTranscript = '';
for (let i = event.resultIndex; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
finalTranscript += event.results[i][0].transcript;
} else {
interimTranscript += event.results[i][0].transcript;
}
}
setTranscript(finalTranscript || interimTranscript);
};
recognitionRef.current.onerror = (event: any) => {
console.error("Speech recognition error", event.error);
setIsListening(false);
if (event.error === 'not-allowed') {
setAiResponse('Microphone access denied. Please allow microphone permissions.');
}
};
recognitionRef.current.onend = () => {
setIsListening(false);
};
} else {
console.error("Speech Recognition API not supported in this browser.");
setAiResponse("Voice recognition is not supported in this browser. Please use Chrome or Edge.");
}
return () => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []); // Run only once to initialize
// We need a separate effect to trigger processing once listening stops and we have a final transcript
useEffect(() => {
if (!isListening && transcript) {
processCommand(transcript);
}
}, [isListening, transcript]);
// Real Intent Parser using Gemini AI backend
const processCommand = async (command: string) => {
if (!command) return;
try {
setAiResponse("Thinking...");
const result = await sendVoiceCommand(command);
if (result.replyText) {
speak(result.replyText);
setAiResponse(result.replyText);
} else {
setAiResponse("I didn't quite understand that.");
}
if (result.actionCommand === 'VIEW_LOW_STOCK') {
navigate('/inventory');
setTimeout(() => {
const event = new CustomEvent('setFilter', { detail: 'low-stock' });
window.dispatchEvent(event);
}, 500);
} else if (result.actionCommand === 'VIEW_EXPIRING_STOCK') {
navigate('/inventory');
setTimeout(() => {
const event = new CustomEvent('setFilter', { detail: 'expiring' });
window.dispatchEvent(event);
}, 500);
} else if (result.actionCommand === 'NAVIGATE_DASHBOARD') {
navigate('/');
} else if (result.actionCommand === 'NAVIGATE_PHARMACY') {
navigate('/pharmacy');
} else if (result.actionCommand === 'NAVIGATE_INVENTORY') {
navigate('/inventory');
setTimeout(() => {
const event = new CustomEvent('setFilter', { detail: 'all' });
window.dispatchEvent(event);
}, 500);
} else if (result.actionCommand === 'NAVIGATE_PRESCRIPTIONS') {
navigate('/prescriptions');
} else if (result.actionCommand === 'NAVIGATE_CATALOGUE') {
navigate('/inventory');
}
} catch (error) {
console.error("AI command error:", error);
setAiResponse("Sorry, there was an error processing your command.");
}
};
const speak = (text: string) => {
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
window.speechSynthesis.speak(utterance);
}
};
const toggleListening = () => {
if (!recognitionRef.current) return;
setIsPanelOpen(true);
if (isListening) {
recognitionRef.current.stop();
} else {
setTranscript('');
setAiResponse('');
recognitionRef.current.start();
}
};
return (
<div className="fixed bottom-8 right-8 z-[9999] flex flex-col items-end gap-4 pointer-events-none">
{/* AI Assistant Chat Panel */}
{isPanelOpen && (
<div className="w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col transform transition-all pointer-events-auto">
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
<div className="flex items-center gap-2">
<Activity className={`w-5 h-5 ${isListening ? 'animate-pulse text-rose-400' : 'text-slate-400'}`} />
<span className="font-semibold text-sm">AI Pharmacy Assistant</span>
</div>
<button onClick={() => setIsPanelOpen(false)} className="text-slate-400 hover:text-white transition-colors">
<X size={18} />
</button>
</div>
<div className="p-5 flex flex-col gap-4 min-h-[200px] max-h-[400px] overflow-y-auto">
{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">
{transcript}
</div>
)}
{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">
{aiResponse}
</div>
)}
{isListening && !transcript && (
<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="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-rose-500"></span>
</span>
Listening...
</div>
)}
</div>
</div>
)}
{/* Floating Action Button */}
<button
onClick={toggleListening}
className={`relative flex items-center justify-center w-16 h-16 rounded-full shadow-2xl transition-all duration-300 pointer-events-auto border-4 ${
isListening
? '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'
}`}
>
{isListening ? (
<MicOff className="text-white w-7 h-7" />
) : (
<Mic className="text-white w-7 h-7" />
)}
{/* Glowing ring animation when listening */}
{isListening && (
<span className="absolute -inset-2 rounded-full border-2 border-rose-400 animate-ping opacity-50"></span>
)}
</button>
</div>
);
};
+207
View File
@@ -0,0 +1,207 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
:root {
/* Brand Colors */
--primary: #0f172a;
--primary-hover: #334155;
--secondary: #3b82f6;
/* Backgrounds */
--bg-main: #f8fafc;
--bg-card: #ffffff;
--bg-sidebar: #ffffff;
--bg-input: #ffffff;
--bg-hover: #f1f5f9;
/* Text */
--text-main: #0f172a;
--text-muted: #64748b;
/* Borders */
--border: #e2e8f0;
--border-focus: rgba(15, 23, 42, 0.2);
/* Effects */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-glow: none;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Typography */
h1, h2, h3, h4 {
font-weight: 700;
letter-spacing: -0.025em;
margin-bottom: 1rem;
}
h1 {
font-size: 2rem;
color: var(--text-main);
}
/* App Layout */
.app-container {
display: flex;
min-height: 100vh;
width: 100%;
}
.sidebar {
width: 260px;
background-color: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
transition: all 0.3s ease;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar-logo {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 2rem;
padding: 0 0.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-main);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: var(--bg-hover);
color: var(--text-main);
}
.nav-item.active {
background-color: #f1f5f9;
color: var(--text-main);
font-weight: 600;
}
.nav-item-icon {
width: 18px;
height: 18px;
}
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
/* Glassmorphism Classes */
.glass-panel {
background: var(--bg-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: 1.5rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.glass-panel:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
/* Inputs & Buttons */
.input-field {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-main);
padding: 0.75rem 1rem;
font-size: 0.875rem;
transition: all 0.2s ease;
outline: none;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--border-focus);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
box-shadow: var(--shadow-glow);
}
/* Utilities */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
.grid-cols-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
+8
View File
@@ -0,0 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<App />
)
+97
View File
@@ -0,0 +1,97 @@
import { Activity, Package, Pill, Users } from 'lucide-react';
import { useState, useEffect } from 'react';
import { getExpiringInventory, authenticate, getAuditLogs } from '../services/api';
const Dashboard = () => {
const [inventoryCount, setInventoryCount] = useState<number | string>('...');
const [recentLogs, setRecentLogs] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
if (!localStorage.getItem('auth_token')) {
await authenticate();
}
const data = await getExpiringInventory();
if (data && data.batches) {
setInventoryCount(data.batches.length);
}
const logsData = await getAuditLogs();
if (logsData && logsData.stock_movements) {
setRecentLogs(logsData.stock_movements);
}
} catch (err) {
console.error("Failed to load dashboard data", err);
setInventoryCount('Error');
}
};
fetchData();
}, []);
const stats = [
{ label: 'Total Patients', value: '1,234', icon: Users, color: 'text-blue-400' },
{ label: 'Prescriptions', value: '856', icon: Activity, color: 'text-green-400' },
{ label: 'Inventory Items', value: inventoryCount, icon: Package, color: 'text-purple-400' },
{ label: 'Dispensed Today', value: '142', icon: Pill, color: 'text-pink-400' },
];
return (
<div className="p-8">
<header className="mb-8">
<h1>AI Pharmacy Dashboard</h1>
<p className="text-gray-400 mt-2">Welcome to your intelligent pharmacy management system.</p>
</header>
<div className="grid-cols-3">
{stats.map((stat, idx) => (
<div key={idx} className="glass-panel flex items-center gap-4">
<div className={`p-3 rounded-full bg-white/10 ${stat.color}`}>
<stat.icon size={24} />
</div>
<div>
<p className="text-sm text-gray-400">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
</div>
))}
</div>
<div className="mt-8 grid-cols-3">
<div className="glass-panel" style={{ gridColumn: 'span 2' }}>
<h2>Recent Activity</h2>
<div className="space-y-4 mt-4">
{recentLogs.length > 0 ? recentLogs.map((log) => {
const isReceipt = log.movement_type === 'receipt';
const date = new Date(log.created_at);
return (
<div key={log.id} className="flex justify-between items-center p-3 bg-white/5 rounded-lg border border-white/5">
<div>
<p className="font-medium">{isReceipt ? 'Stock Added' : 'Prescription Dispensed'}</p>
<p className="text-sm text-gray-400">
{isReceipt ? 'Received' : 'Dispensed'} {Math.abs(log.qty_delta)} items (Batch {log.batch_no})
</p>
</div>
<span className="text-xs text-gray-400">{date.toLocaleDateString()} {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
);
}) : (
<p className="text-sm text-gray-400">No recent activity.</p>
)}
</div>
</div>
<div className="glass-panel">
<h2>Quick Actions</h2>
<div className="space-y-4 mt-4 flex flex-col">
<button className="btn btn-primary w-full">New Prescription</button>
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">Add Inventory</button>
<button className="btn bg-white/10 hover:bg-white/20 w-full text-white">View Reports</button>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;
+234
View File
@@ -0,0 +1,234 @@
import React, { useState, useEffect, useMemo } from 'react';
import { History as HistoryIcon, Clock, Package, AlertTriangle, Search } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { getDispensingHistory } from '../services/api';
interface DispensingHistoryRecord {
dispensing_id: string;
created_at: string;
patient_name: string;
prescription_id: string | null;
items: any[];
total_amount: number;
payment_method: string;
}
const columnHelper = createColumnHelper<DispensingHistoryRecord>();
const columns = [
columnHelper.accessor('dispensing_id', {
header: 'DISPENSING ID',
size: 150,
cell: info => (
<span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 tracking-wide font-bold">
{info.getValue()}
</span>
)
}),
columnHelper.accessor('created_at', {
header: 'DATE',
size: 180,
cell: info => {
const date = new Date(info.getValue());
return <span className="text-slate-600 font-medium text-[13px]">{date.toLocaleString()}</span>;
}
}),
columnHelper.accessor('patient_name', {
header: 'PATIENT NAME',
size: 200,
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('prescription_id', {
header: 'PRESCRIPTION REF',
size: 180,
cell: info => {
const val = info.getValue();
return val ? (
<span className="text-blue-600 font-bold text-[13px] tracking-wide">{val}</span>
) : (
<span className="text-slate-400 text-[13px] italic font-medium">Direct Walk-in</span>
);
}
}),
columnHelper.accessor('items', {
header: 'ITEMS DISPENSED',
size: 350,
cell: info => {
const items = info.getValue() || [];
return (
<div className="space-y-1.5 py-1">
{items.map((item: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-[13px]">
<Package size={14} className="text-slate-400 shrink-0" />
<span className="font-bold text-slate-700 truncate">{item.brand_name}</span>
<span className="text-slate-500 font-medium shrink-0">x {item.qty}</span>
<span className="text-[10px] font-bold text-slate-400 border border-slate-200 px-1 rounded uppercase bg-slate-50 shrink-0 tracking-wider">
{item.batch_no}
</span>
</div>
))}
</div>
);
}
}),
columnHelper.accessor('total_amount', {
header: 'TOTAL AMOUNT',
size: 150,
cell: info => {
const val = info.getValue();
return <span className="font-bold text-slate-800 text-[14px]">₹{val ? Number(val).toFixed(2) : '0.00'}</span>;
}
}),
columnHelper.accessor('payment_method', {
header: 'PAYMENT',
size: 120,
cell: info => {
const val = info.getValue() || 'CASH';
return (
<span className="uppercase text-[11px] font-bold tracking-wide text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-md border border-emerald-100">
{val}
</span>
);
}
}),
];
const History = () => {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [historyList, setHistoryList] = useState<DispensingHistoryRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchHistory();
}, []);
const fetchHistory = async () => {
try {
setIsLoading(true);
setError(null);
const data = await getDispensingHistory();
if (data && data.history) {
setHistoryList(data.history);
}
} catch (err) {
console.error("Failed to load history", err);
setError("Failed to load dispensing history.");
} finally {
setIsLoading(false);
}
};
// Filter based on search query
const filteredHistory = useMemo(() => {
return historyList.filter(item =>
item.patient_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.dispensing_id.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.prescription_id && item.prescription_id.toLowerCase().includes(searchQuery.toLowerCase()))
);
}, [historyList, searchQuery]);
// Pagination logic
const totalItems = filteredHistory.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredHistory.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
{/* Top Header */}
<header className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="p-2.5 bg-blue-100 text-blue-600 rounded-xl">
<HistoryIcon size={22} />
</div>
<h1 className="text-2xl font-bold text-slate-800">Dispensing History</h1>
</div>
<p className="text-slate-500 mt-1 ml-12">View all past orders and dispensed medicines.</p>
</div>
</header>
{/* Action Bar with Spotlight Search */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
<div className="relative w-full max-w-2xl group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}>
<Search size={20} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by patient name, prescription ID or Dispensing ID..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/>
</div>
</div>
{/* Table Card */}
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden">
<div className="overflow-x-auto">
{error ? (
<div className="p-8 text-center text-red-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={fetchHistory}
className="mt-4 px-4 py-2 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors font-medium"
>
Try Again
</button>
</div>
) : isLoading ? (
<div className="p-8 flex flex-col items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
<p className="text-slate-500 font-medium">Loading history records...</p>
</div>
) : historyList.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Clock className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Records Found</h3>
<p className="max-w-md mx-auto">No dispensing history found. When you dispense orders, they will appear here.</p>
<button
onClick={fetchHistory}
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm font-bold"
>
Refresh History
</button>
</div>
) : (
<Table
data={currentData}
columns={columns}
getRowId={(item) => item.dispensing_id}
/>
)}
</div>
{totalItems > 0 && !isLoading && !error && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onPageChange={(page) => setCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setItemsPerPage(limit);
setCurrentPage(1);
}}
/>
)}
</div>
</div>
);
};
export default History;
+611
View File
@@ -0,0 +1,611 @@
import { useState, useEffect, useMemo } from 'react';
import { Search, Bell, Settings, User, Package, Plus, AlertTriangle, X, Calendar, Hash, IndianRupee, Filter, ChevronDown } from 'lucide-react';
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';
interface InventoryBatch {
batch_no: string;
product_id: string;
brand_name: string;
qty_on_hand: number;
expiry: string;
days_to_expiry: number;
}
const columnHelper = createColumnHelper<InventoryBatch>();
const columns = [
columnHelper.accessor('brand_name', {
header: 'MEDICINE',
size: 250,
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('batch_no', {
header: 'BATCH NO',
size: 150,
cell: info => (
<span className="bg-slate-100 text-slate-600 px-3 py-1 rounded font-bold text-[11px] tracking-wide">
{info.getValue()}
</span>
)
}),
columnHelper.accessor('qty_on_hand', {
header: 'QTY ON HAND',
size: 150,
cell: info => (
<span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
)
}),
columnHelper.accessor('expiry', {
header: 'EXPIRY DATE',
size: 180,
cell: info => {
const date = new Date(info.getValue());
return <span className="text-slate-600 font-medium text-[14px]">{date.toLocaleDateString()}</span>;
}
}),
columnHelper.accessor('days_to_expiry', {
header: 'DAYS TO EXPIRY',
size: 150,
cell: info => {
const days = info.getValue();
let colorClass = "text-emerald-600 bg-emerald-50";
let displayText = `${days} Days`;
if (days < 0) {
colorClass = "text-rose-700 bg-rose-100 border border-rose-200";
displayText = "Expired";
} else if (days === 0) {
colorClass = "text-rose-600 bg-rose-50 border border-rose-200";
displayText = "Expires Today";
} else if (days < 30) {
colorClass = "text-rose-600 bg-rose-50";
} else if (days < 90) {
colorClass = "text-amber-600 bg-amber-50";
}
return (
<span className={`px-3 py-1 rounded font-bold text-[12px] tracking-wide ${colorClass}`}>
{displayText}
</span>
);
}
}),
];
const Inventory = () => {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [inventory, setInventory] = useState<InventoryBatch[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddingStock, setIsAddingStock] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'low-stock' | 'expiring'>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [drugOptions, setDrugOptions] = useState<any[]>([]);
const [stockMode, setStockMode] = useState<'restock' | 'new'>('restock');
const [newMedicine, setNewMedicine] = useState({
brand_name: '',
generic_name: '',
manufacturer: '',
form: 'tablet',
strength: '',
schedule: 'H'
});
const [formData, setFormData] = useState({
supplier_id: '',
invoice_no: '',
product_id: '',
batch_no: '',
expiry: '',
qty: '',
purchase_price: ''
});
const submitStock = async () => {
try {
setIsAddingStock(true);
const timestamp = Date.now();
let finalProductId = formData.product_id;
if (stockMode === 'new') {
const medPayload = {
brand_name: newMedicine.brand_name,
generic_name: newMedicine.generic_name,
manufacturer: newMedicine.manufacturer,
form: newMedicine.form,
strength: newMedicine.strength,
schedule: newMedicine.schedule
};
const medRes = await addMedicine(medPayload);
if (medRes && medRes.product_id) {
finalProductId = medRes.product_id;
} else {
throw new Error("Failed to generate Product ID for new medicine");
}
}
const payload = {
supplier_id: formData.supplier_id,
invoice_no: formData.invoice_no || `INV-${timestamp}`,
lines: [
{
batch_no: formData.batch_no.trim() || `BAT-${timestamp}`,
product_id: finalProductId,
expiry: formData.expiry,
qty: formData.qty,
purchase_price: formData.purchase_price
}
]
};
await addStock(payload);
// Refresh the inventory data after successful addition
await loadData();
setIsModalOpen(false); // close the modal on success
// Reset state
setStockMode('restock');
setNewMedicine({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', schedule: 'H' });
setFormData({ supplier_id: '', invoice_no: '', product_id: '', batch_no: '', expiry: '', qty: '', purchase_price: '' });
alert("Stock added successfully! Inventory count increased.");
} catch (err) {
console.error("Failed to add stock:", err);
alert("Failed to add stock. Please try again.");
} finally {
setIsAddingStock(false);
}
};
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
if (!localStorage.getItem('auth_token')) {
await authenticate();
}
let data: any;
if (filterMode === 'low-stock') {
data = await getLowStockInventory();
} else if (filterMode === 'expiring') {
data = await getExpiringInventory(); // defaults to 3650 for all, we should probably pass 30 here for 'near expiry'
} else {
data = await getExpiringInventory();
}
if (data && data.batches) {
// If filterMode is expiring, we actually want near expiry
if (filterMode === 'expiring') {
const nearExpiry = data.batches.filter((b: any) => b.days_to_expiry && b.days_to_expiry <= 30);
setInventory(nearExpiry);
} else {
setInventory(data.batches);
}
}
const drugsData = await getDrugs();
if (drugsData && drugsData.drugs) {
setDrugOptions(drugsData.drugs);
}
} catch (err: any) {
if (err.response && err.response.status === 401) {
try {
await authenticate();
const retryData = await getExpiringInventory();
if (retryData && retryData.batches) {
setInventory(retryData.batches);
}
} catch (retryError) {
console.error("Failed to fetch inventory after retry:", retryError);
setError("Authentication failed. Please try logging in again.");
}
} else {
console.error("Failed to fetch inventory:", err);
setError("Failed to load inventory data.");
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, [filterMode]);
useEffect(() => {
const handleSetFilter = (e: any) => {
if (e.detail) {
setFilterMode(e.detail);
}
};
window.addEventListener('setFilter', handleSetFilter);
return () => window.removeEventListener('setFilter', handleSetFilter);
}, []);
// Filter based on search query
const filteredInventory = useMemo(() => {
return inventory.filter(item =>
item.brand_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.batch_no.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [inventory, searchQuery]);
// Pagination logic
const totalItems = filteredInventory.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredInventory.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
{/* Top Header */}
<header className="mb-6 flex items-center justify-between">
<div>
<h1>Inventory</h1>
</div>
</header>
{/* Filter Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-2">
<button
onClick={() => setFilterMode('all')}
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'all' ? 'bg-blue-600 border-blue-600 text-white shadow-lg shadow-blue-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-blue-300 hover:shadow-md'}`}
style={{ padding: '24px' }}
>
<div className={`rounded-xl ${filterMode === 'all' ? 'bg-white/20' : 'bg-blue-50 text-blue-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
<Package size={24} />
</div>
<span className="text-lg font-bold">All Inventory</span>
<span className={`text-sm mt-1 ${filterMode === 'all' ? 'text-blue-100' : 'text-slate-500'}`}>View all stock</span>
</button>
<button
onClick={() => setFilterMode('low-stock')}
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'low-stock' ? 'bg-amber-500 border-amber-500 text-white shadow-lg shadow-amber-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-amber-300 hover:shadow-md'}`}
style={{ padding: '24px' }}
>
<div className={`rounded-xl ${filterMode === 'low-stock' ? 'bg-white/20' : 'bg-amber-50 text-amber-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
<AlertTriangle size={24} />
</div>
<span className="text-lg font-bold">Low Stock</span>
<span className={`text-sm mt-1 ${filterMode === 'low-stock' ? 'text-amber-100' : 'text-slate-500'}`}>Stock ≤ 50 units</span>
</button>
<button
onClick={() => setFilterMode('expiring')}
className={`flex flex-col items-start rounded-2xl border transition-all duration-200 ${filterMode === 'expiring' ? 'bg-rose-500 border-rose-500 text-white shadow-lg shadow-rose-500/30 transform scale-[1.02]' : 'bg-white border-slate-200 text-slate-700 hover:border-rose-300 hover:shadow-md'}`}
style={{ padding: '24px' }}
>
<div className={`rounded-xl ${filterMode === 'expiring' ? 'bg-white/20' : 'bg-rose-50 text-rose-600'}`} style={{ padding: '12px', marginBottom: '16px' }}>
<Calendar size={24} />
</div>
<span className="text-lg font-bold">Near Expiry</span>
<span className={`text-sm mt-1 ${filterMode === 'expiring' ? 'text-rose-100' : 'text-slate-500'}`}>Expiring in ≤ 30 days</span>
</button>
</div>
{/* Action Bar with Spotlight Search */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
<div className="relative w-full max-w-2xl group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}>
<Search size={20} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search batches by medicine or batch no..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 md:flex-none flex items-center justify-center gap-2 rounded-xl font-bold text-[13px] tracking-wide transition-colors shadow-sm bg-blue-600 hover:bg-blue-700 text-white"
style={{ padding: '12px 24px' }}
>
<Plus size={18} />
ADD STOCK
</button>
</div>
</div>
{/* Table Card */}
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden" style={{ marginTop: '16px' }}>
<div className="overflow-x-auto">
{error ? (
<div className="p-8 text-center text-red-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={loadData}
className="mt-4 px-4 py-2 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors"
>
Try Again
</button>
</div>
) : isLoading ? (
<div className="p-8 flex flex-col items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
<p className="text-slate-500">Loading inventory records...</p>
</div>
) : inventory.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Package className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Records Found</h3>
<p className="max-w-md mx-auto">Your inventory is empty or no batches are expiring.</p>
<button
onClick={loadData}
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
Refresh Inventory
</button>
</div>
) : (
<Table
data={currentData}
columns={columns}
getRowId={(item) => item.batch_no}
/>
)}
</div>
{totalItems > 0 && !isLoading && !error && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onPageChange={(page) => setCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setItemsPerPage(limit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* Add Stock Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="border-b border-slate-100 flex items-center justify-between shrink-0" style={{ padding: '20px 32px' }}>
<h3 className="text-xl font-bold text-slate-800">Add Stock (GRN)</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
style={{ padding: '8px' }}
>
<X size={20} />
</button>
</div>
<div className="flex border-b border-slate-200 bg-slate-50/50 px-6 shrink-0">
<button
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'}`}
>
Restock Existing
</button>
<button
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'}`}
>
Add New Medicine
</button>
</div>
<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>
</div>
) : (
<>
<div className="col-span-2 border-b border-slate-100 pb-2 mb-2">
<h4 className="font-bold text-slate-800 text-sm">Medicine Details</h4>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Brand Name</label>
<input type="text" value={newMedicine.brand_name} onChange={e => setNewMedicine({...newMedicine, brand_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Crocin Advance" />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Generic Name</label>
<input type="text" value={newMedicine.generic_name} onChange={e => setNewMedicine({...newMedicine, generic_name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. Paracetamol" />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Manufacturer</label>
<input type="text" value={newMedicine.manufacturer} onChange={e => setNewMedicine({...newMedicine, manufacturer: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. GSK" />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Strength</label>
<input type="text" value={newMedicine.strength} onChange={e => setNewMedicine({...newMedicine, strength: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400" style={{ padding: '12px 16px' }} placeholder="e.g. 500mg" />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Form</label>
<select value={newMedicine.form} onChange={e => setNewMedicine({...newMedicine, form: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
<option value="tablet">Tablet</option>
<option value="capsule">Capsule</option>
<option value="syrup">Syrup</option>
<option value="injection">Injection</option>
</select>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Schedule</label>
<select value={newMedicine.schedule} onChange={e => setNewMedicine({...newMedicine, schedule: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300" style={{ padding: '12px 16px' }}>
<option value="H">Schedule H</option>
<option value="H1">Schedule H1</option>
<option value="X">Schedule X</option>
<option value="OTC">OTC</option>
</select>
</div>
<div className="col-span-2 border-b border-slate-100 pb-2 mb-2 mt-2">
<h4 className="font-bold text-slate-800 text-sm">Stock Details</h4>
</div>
</>
)}
{/* GRN Details */}
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Supplier ID</label>
<input
type="text"
value={formData.supplier_id}
onChange={(e) => setFormData({ ...formData, supplier_id: e.target.value })}
placeholder="sup_001"
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' }}
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Invoice No</label>
<input
type="text"
value={formData.invoice_no}
onChange={(e) => setFormData({ ...formData, invoice_no: e.target.value })}
placeholder="Auto-generated if empty"
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' }}
/>
</div>
{/* Batch No & Expiry Date */}
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Batch No</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">
<Hash size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.batch_no}
onChange={(e) => setFormData({ ...formData, batch_no: e.target.value })}
placeholder="Auto-generated if empty"
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>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Expiry Date</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">
<Calendar size={18} strokeWidth={2} />
</div>
<input
type="date"
value={formData.expiry}
onChange={(e) => setFormData({ ...formData, expiry: 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', paddingLeft: '44px' }}
/>
</div>
</div>
{/* Quantity */}
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Quantity</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">
<Hash size={18} strokeWidth={2} />
</div>
<input
type="number"
value={formData.qty}
onChange={(e) => setFormData({ ...formData, qty: e.target.value })}
placeholder="Enter quantity"
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>
{/* Purchase Price */}
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Purchase Price</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-emerald-500 transition-colors">
<IndianRupee size={18} strokeWidth={2} />
</div>
<input
type="text"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
placeholder="0.00"
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px' }}
/>
</div>
</div>
</div>
<div className="border-t border-slate-100 bg-slate-50 flex justify-end shrink-0" style={{ padding: '20px 32px', gap: '16px' }}>
<button
onClick={() => setIsModalOpen(false)}
className="shrink-0 text-[15px] font-bold text-slate-600 hover:bg-slate-200 bg-slate-200/50 rounded-xl transition-colors"
style={{ padding: '12px 24px' }}
>
Cancel
</button>
<button
onClick={submitStock}
disabled={isAddingStock}
className={`shrink-0 text-[15px] font-bold text-white rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2 ${isAddingStock ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
}`}
style={{ padding: '12px 32px' }}
>
{isAddingStock ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Saving...
</>
) : (
'Save Stock'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Inventory;
+611
View File
@@ -0,0 +1,611 @@
import { useState, useEffect, useMemo } from 'react';
import { Search, AlertCircle, Package, Layers, Plus, X, ChevronDown } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { CustomSelect } from '../components/CustomSelect';
import { getDrugs, authenticate, addMedicine, createOrderDraft, updateOrderItems, precheckOrder, dispenseOrder } from '../services/api';
interface PharmacyItem {
id: string;
medicine: string;
subtitle: string;
badge: string;
stock: number;
rawPrice: number;
price: string;
manufacture: string;
type: string;
}
const columnHelper = createColumnHelper<PharmacyItem>();
const columns = [
columnHelper.accessor('medicine', {
header: 'Medicine',
size: 350,
cell: info => {
const item = info.row.original;
return (
<div className="py-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800 text-[14px]">{item.medicine}</span>
{item.badge && (
<span className="bg-blue-600 text-white text-[10px] px-2 py-0.5 rounded font-semibold uppercase tracking-wide">
{item.badge}
</span>
)}
</div>
<span className="text-[13px] text-slate-400 block mt-1">{item.subtitle}</span>
</div>
);
}
}),
columnHelper.accessor('stock', {
header: 'Stock',
size: 100,
cell: info => {
const stock = info.getValue();
const isLow = stock < 50;
return (
<span className={`font-medium text-[14px] ${isLow ? 'text-red-500' : 'text-slate-800'}`}>
{stock}
</span>
);
}
}),
columnHelper.accessor('price', {
header: 'Price',
size: 120,
cell: info => <span className="text-slate-800 font-medium text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('manufacture', {
header: 'Manufacturer',
size: 180,
cell: info => <span className="text-slate-600 text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('type', {
header: 'Type',
size: 100,
cell: info => <span className="text-slate-600 text-[14px] capitalize">{info.getValue()}</span>
}),
];
const Pharmacy = () => {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [inventory, setInventory] = useState<PharmacyItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Add Medicine Modal State
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [addFormData, setAddFormData] = useState({
brand_name: '',
generic_name: '',
manufacturer: '',
form: 'tablet',
strength: '',
mrp: '',
schedule: 'H'
});
// Dispense Modal State
const [isDispenseModalOpen, setIsDispenseModalOpen] = useState(false);
const [dispenseStep, setDispenseStep] = useState(1); // 1: items, 2: precheck, 3: success
const [orderDraftId, setOrderDraftId] = useState<string | null>(null);
const [patientName, setPatientName] = useState('');
const [prescriberName, setPrescriberName] = useState('');
const [selectedItems, setSelectedItems] = useState<{product_id: string, name: string, qty: number, price: number}[]>([]);
const [precheckResult, setPrecheckResult] = useState<any>(null);
const [isProcessingDispense, setIsProcessingDispense] = useState(false);
const resetDispenseModal = () => {
setIsDispenseModalOpen(false);
setDispenseStep(1);
setOrderDraftId(null);
setPatientName('');
setSelectedItems([]);
setPrecheckResult(null);
};
const handleCreateDraft = async () => {
if (!patientName || selectedItems.length === 0) return alert("Enter patient name and select items.");
try {
setIsProcessingDispense(true);
const draft = await createOrderDraft({ subject_ref: patientName });
setOrderDraftId(draft.order_draft_id);
const itemsPayload = selectedItems.map(i => ({ product_id: i.product_id, qty: i.qty }));
await updateOrderItems(draft.order_draft_id, itemsPayload);
const precheck = await precheckOrder(draft.order_draft_id, {
clinical_context: { patient_id: patientName, allergies: [] },
items: itemsPayload
});
setPrecheckResult(precheck);
setDispenseStep(2);
} catch (e) {
console.error(e);
alert("Failed to prepare order.");
} finally {
setIsProcessingDispense(false);
}
};
const handleExecuteDispense = async () => {
if (!orderDraftId) return;
try {
setIsProcessingDispense(true);
await dispenseOrder(orderDraftId, {
subject_ref: patientName,
prescriber_name: prescriberName,
payment_method: 'cash'
});
setDispenseStep(3);
loadData(); // refresh inventory
} catch (e: any) {
console.error(e);
alert("Dispense failed: " + (e.response?.data?.message || "Check stock."));
} finally {
setIsProcessingDispense(false);
}
};
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsAdding(true);
await addMedicine(addFormData);
setIsAddModalOpen(false);
setAddFormData({ brand_name: '', generic_name: '', manufacturer: '', form: 'tablet', strength: '', mrp: '', schedule: 'H' });
await loadData();
} catch (err) {
console.error("Failed to add medicine", err);
alert("Failed to add medicine. Please try again.");
} finally {
setIsAdding(false);
}
};
const mapAndSetData = (drugs: any[]) => {
const mappedData: PharmacyItem[] = drugs.map((drug: any) => ({
id: drug.product_id,
medicine: `${drug.brand_name} ${drug.strength || ''}`.trim(),
subtitle: drug.generic_name || '',
badge: drug.schedule || '',
stock: parseInt(drug.total_stock) || 0,
rawPrice: parseFloat(drug.mrp) || 0,
price: new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(parseFloat(drug.mrp) || 0),
manufacture: drug.manufacturer || 'Unknown',
type: drug.form || 'Unknown'
}));
setInventory(mappedData);
};
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
if (!localStorage.getItem('auth_token')) {
await authenticate();
}
const data = await getDrugs();
if (data && data.drugs) {
mapAndSetData(data.drugs);
}
} catch (error: any) {
if (error.response && error.response.status === 401) {
try {
await authenticate();
const retryData = await getDrugs();
if (retryData && retryData.drugs) {
mapAndSetData(retryData.drugs);
}
} catch (retryError) {
console.error("Failed to fetch drugs after retry:", retryError);
setError("Authentication failed. Please try logging in again.");
}
} else {
console.error("Failed to fetch drugs:", error);
setError("Failed to connect to the server. Please try again later.");
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// Filter inventory based on search query
const filteredInventory = useMemo(() => {
return inventory.filter(item =>
item.medicine.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.subtitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.manufacture.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [inventory, searchQuery]);
// Reset pagination when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchQuery]);
const totalItems = filteredInventory.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredInventory.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// Business Metrics
const lowStockCount = inventory.filter(item => item.stock < 50).length;
const uniqueCategories = new Set(inventory.map(item => item.type)).size;
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
<header className="mb-6 shrink-0 flex items-center justify-between">
<div>
<h1>Pharmacy Overview</h1>
</div>
</header>
{/* Metrics Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0" style={{ marginBottom: '32px' }}>
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center shrink-0">
<Package className="text-blue-600" size={24} />
</div>
<div>
<p className="text-slate-500 text-[13px] font-medium mb-1">Total Medicines</p>
<h3 className="text-2xl font-bold text-slate-900">{inventory.length}</h3>
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center shrink-0">
<AlertCircle className="text-red-500" size={24} />
</div>
<div>
<p className="text-slate-500 text-[13px] font-medium mb-1">Low Stock Alerts</p>
<h3 className="text-2xl font-bold text-slate-900">{lowStockCount}</h3>
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-slate-200 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
<Layers className="text-indigo-500" size={24} />
</div>
<div>
<p className="text-slate-500 text-[13px] font-medium mb-1">Unique Categories</p>
<h3 className="text-2xl font-bold text-slate-900">{uniqueCategories}</h3>
</div>
</div>
</div>
{/* Action Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
<div className="relative w-full max-w-2xl group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}>
<Search size={20} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search medicines, OTC, or prescriptions..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/>
</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>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center gap-2 bg-blue-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-blue-700 transition-colors shadow-sm whitespace-nowrap"
>
<Plus size={18} strokeWidth={2.5} />
Add Medicine
</button>
</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="overflow-x-auto">
{error ? (
<div className="p-8 text-center text-red-500">
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={loadData}
className="mt-4 px-4 py-2 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors"
>
Try Again
</button>
</div>
) : isLoading ? (
<div className="p-8 flex flex-col items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
<p className="text-slate-500">Loading pharmacy records...</p>
</div>
) : inventory.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Package className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No Medicines Found</h3>
<p className="max-w-md mx-auto">Your pharmacy inventory is currently empty or no items match your search.</p>
<button
onClick={loadData}
className="mt-6 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
Refresh Inventory
</button>
</div>
) : (
<Table
data={currentData}
columns={columns}
getRowId={(item) => item.id}
/>
)}
</div>
{totalItems > 0 && !isLoading && !error && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onPageChange={(page) => setCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setItemsPerPage(limit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* Add Medicine Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50" style={{ padding: '24px' }}>
<h2 className="text-xl font-bold text-slate-800">Add New Medicine to Catalogue</h2>
<button
onClick={() => setIsAddModalOpen(false)}
className="text-slate-400 hover:text-slate-600 transition-colors p-2 hover:bg-slate-100 rounded-full"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleAddSubmit} style={{ padding: '24px' }}>
<div className="grid grid-cols-2" style={{ gap: '24px', marginBottom: '24px' }}>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Brand Name</label>
<input required type="text" value={addFormData.brand_name} onChange={e => setAddFormData({...addFormData, 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" placeholder="e.g. Calpol 500" style={{ padding: '12px 16px' }} />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Generic Name</label>
<input required type="text" value={addFormData.generic_name} onChange={e => setAddFormData({...addFormData, 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" placeholder="e.g. Paracetamol" style={{ padding: '12px 16px' }} />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Manufacturer</label>
<input type="text" value={addFormData.manufacturer} onChange={e => setAddFormData({...addFormData, 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" placeholder="e.g. GSK" style={{ padding: '12px 16px' }} />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Form</label>
<CustomSelect value={addFormData.form} onChange={e => setAddFormData({...addFormData, form: e.target.value})}>
<option value="tablet">Tablet</option>
<option value="capsule">Capsule</option>
<option value="syrup">Syrup</option>
<option value="injection">Injection</option>
<option value="ointment">Ointment</option>
</CustomSelect>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Strength</label>
<input type="text" value={addFormData.strength} onChange={e => setAddFormData({...addFormData, 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" placeholder="e.g. 500mg" style={{ padding: '12px 16px' }} />
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>MRP (₹)</label>
<input required type="number" step="0.01" value={addFormData.mrp} onChange={e => 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' }} />
</div>
</div>
<div className="flex items-center justify-end border-t border-slate-100" style={{ paddingTop: '16px', gap: '12px' }}>
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition-colors"
style={{ padding: '10px 20px' }}
>
Cancel
</button>
<button
type="submit"
disabled={isAdding}
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
style={{ padding: '10px 20px', gap: '8px' }}
>
{isAdding ? 'Adding...' : 'Add Medicine'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Dispense Modal */}
{isDispenseModalOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50" style={{ padding: '24px' }}>
<h2 className="text-xl font-bold text-slate-800">Manual Dispense</h2>
<button
onClick={resetDispenseModal}
className="text-slate-400 hover:text-slate-600 transition-colors p-2 hover:bg-slate-100 rounded-full"
>
<X size={20} />
</button>
</div>
<div style={{ padding: '24px' }}>
{dispenseStep === 1 && (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2" style={{ gap: '16px', marginBottom: '16px' }}>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Patient Name</label>
<input
type="text"
value={patientName}
onChange={e => setPatientName(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"
style={{ padding: '12px 16px' }}
placeholder="e.g. John Doe"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Prescriber Name</label>
<input
type="text"
value={prescriberName}
onChange={e => setPrescriberName(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"
style={{ padding: '12px 16px' }}
placeholder="e.g. Dr. Smith"
/>
</div>
</div>
<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 }]);
}
}}
>
<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 && (
<div style={{ marginBottom: '16px' }}>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Selected Items</label>
<div className="space-y-2">
{selectedItems.map((item, idx) => (
<div key={idx} className="flex items-center bg-white border border-slate-200 rounded-lg" style={{ gap: '16px', padding: '12px' }}>
<span className="flex-1 font-medium">{item.name}</span>
<input
type="number"
min="1"
value={item.qty}
onChange={(e) => {
const newItems = [...selectedItems];
newItems[idx].qty = parseInt(e.target.value) || 1;
setSelectedItems(newItems);
}}
className="w-20 border border-slate-200 rounded-md"
style={{ padding: '4px 8px' }}
/>
<button className="text-red-500" onClick={() => setSelectedItems(selectedItems.filter((_, i) => i !== idx))}>
<X size={16} />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end border-t border-slate-100" style={{ paddingTop: '16px' }}>
<button
onClick={handleCreateDraft}
disabled={isProcessingDispense}
className="bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-70"
style={{ padding: '10px 24px' }}
>
{isProcessingDispense ? 'Preparing...' : 'Run Precheck'}
</button>
</div>
</div>
)}
{dispenseStep === 2 && precheckResult && (
<div>
<h3 className="font-bold text-slate-800" style={{ marginBottom: '8px' }}>Safety Pre-Check Results</h3>
<div className="space-y-2 bg-slate-50 rounded-xl border border-slate-200" style={{ marginBottom: '16px', padding: '16px' }}>
{precheckResult.warnings?.map((w: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-sm text-amber-700 font-medium">
<AlertCircle size={16} /> {w.code} - {w.batch_no}
</div>
))}
{(!precheckResult.warnings || precheckResult.warnings.length === 0) && (
<div className="flex items-center gap-2 text-sm text-emerald-600 font-medium">
All checks passed. No warnings.
</div>
)}
</div>
<div className="flex justify-between items-center" style={{ marginBottom: '24px' }}>
<span className="font-bold text-slate-600">Total Bill Amount</span>
<span className="text-xl font-bold text-slate-900">₹{precheckResult.total_amount?.toFixed(2) || '0.00'}</span>
</div>
<div className="flex justify-end border-t border-slate-100" style={{ paddingTop: '16px' }}>
<button
onClick={handleExecuteDispense}
disabled={isProcessingDispense}
className="bg-emerald-600 text-white font-semibold rounded-xl hover:bg-emerald-700 transition-colors shadow-sm disabled:opacity-70 flex items-center gap-2"
style={{ padding: '10px 24px' }}
>
<Package size={18} />
{isProcessingDispense ? 'Dispensing...' : 'Confirm Dispense'}
</button>
</div>
</div>
)}
{dispenseStep === 3 && (
<div className="text-center" style={{ padding: '32px 0' }}>
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto" style={{ marginBottom: '16px' }}>
<Package className="text-emerald-600" size={32} />
</div>
<h3 className="font-bold text-emerald-800 text-xl" style={{ marginBottom: '8px' }}>Dispense Successful!</h3>
<p className="text-emerald-600" style={{ marginBottom: '24px' }}>Stock has been deducted and logged.</p>
<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>
);
};
export default Pharmacy;
+603
View File
@@ -0,0 +1,603 @@
import React, { useState, useEffect } from 'react';
import { UploadCloud, FileText, CheckCircle, AlertTriangle, Loader2, Pill, Eye } 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';
import Pagination from '../components/Pagination';
const Prescriptions = () => {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
// State for the uploaded prescription processing
const [recordId, setRecordId] = useState<string | null>(null);
const [prescriptionData, setPrescriptionData] = useState<any>(null);
const [isPolling, setIsPolling] = useState(false);
// Confirmation state
const [isConfirming, setIsConfirming] = useState(false);
const [isConfirmed, setIsConfirmed] = useState(false);
// Dispensing state
const [isPrechecking, setIsPrechecking] = useState(false);
const [precheckResult, setPrecheckResult] = useState<any>(null);
const [isDispensing, setIsDispensing] = useState(false);
const [dispenseSuccess, setDispenseSuccess] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('cash');
const [drugOptions, setDrugOptions] = useState<any[]>([]);
// Queue State
const [prescriptionsList, setPrescriptionsList] = useState<any[]>([]);
const [queueCurrentPage, setQueueCurrentPage] = useState(1);
const [queueItemsPerPage, setQueueItemsPerPage] = useState(10);
useEffect(() => {
fetchPrescriptions();
getDrugs().then(data => {
if (data && data.drugs) setDrugOptions(data.drugs);
}).catch(console.error);
}, []);
const fetchPrescriptions = async () => {
try {
const data = await getAllPrescriptions();
if (data && data.prescriptions) {
setPrescriptionsList(data.prescriptions);
}
} catch (err) {
console.error("Failed to load queue", err);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
setUploadError(null);
setPrescriptionData(null);
setIsConfirmed(false);
setPrecheckResult(null);
setDispenseSuccess(false);
setPaymentMethod('cash');
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadError(null);
const response = await uploadPrescription(file);
setRecordId(response.id); // Typically returns the new ID
// Start polling
pollForDetails(response.id);
} catch (err: any) {
console.error("Upload failed", err);
setUploadError("Failed to upload prescription. Please try again.");
setIsUploading(false);
}
};
const pollForDetails = async (id: string) => {
setIsPolling(true);
let attempts = 0;
const interval = setInterval(async () => {
try {
attempts++;
const data = await getPrescriptionDetails(id);
if (data.status === 'completed' || data.status === 'requires_review' || data.status === 'pending_review') {
clearInterval(interval);
setPrescriptionData(data);
setIsPolling(false);
setIsUploading(false);
} else if (data.status === 'failed') {
clearInterval(interval);
setUploadError("AI Extraction failed. Please try a clearer image.");
setIsPolling(false);
setIsUploading(false);
} else if (attempts > 30) {
// Timeout after ~30 seconds (assuming 1s interval)
clearInterval(interval);
setUploadError("Processing timed out. Please try again.");
setIsPolling(false);
setIsUploading(false);
}
} catch (err) {
console.error("Polling error", err);
}
}, 1500);
};
const columnHelper = createColumnHelper<any>();
const queueColumns = React.useMemo(() => [
columnHelper.accessor('id', {
header: 'ID',
size: 150,
cell: info => <span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 font-bold tracking-wide">{info.getValue()}</span>
}),
columnHelper.accessor('created_at', {
header: 'DATE',
size: 200,
cell: info => <span className="text-slate-600 font-medium text-[13px]">{new Date(info.getValue()).toLocaleString()}</span>
}),
columnHelper.accessor('status', {
header: 'STATUS',
size: 150,
cell: info => {
const status = info.getValue();
return (
<span className={`px-2.5 py-1 rounded-md text-[11px] font-bold tracking-wide uppercase ${
status === 'confirmed' ? 'bg-emerald-100 text-emerald-700 border border-emerald-200' :
status === 'processing' ? 'bg-amber-100 text-amber-700 border border-amber-200' :
'bg-blue-100 text-blue-700 border border-blue-200'
}`}>
{status}
</span>
);
}
}),
columnHelper.display({
id: 'action',
header: 'ACTION',
size: 100,
cell: info => {
const rx = info.row.original;
return (
<button
onClick={() => {
setRecordId(rx.id);
if (rx.status === 'confirmed' && rx.confirmed_data) {
setPrescriptionData({ extracted_data: rx.confirmed_data.extracted_data || rx.confirmed_data });
setIsConfirmed(true);
} else if (rx.extracted_data) {
setPrescriptionData({ extracted_data: rx.extracted_data });
setIsConfirmed(false);
} else {
pollForDetails(rx.id);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-[13px] font-bold transition-colors shadow-sm"
>
<Eye size={14} /> View
</button>
)
}
}),
], [setRecordId, setPrescriptionData, setIsConfirmed]);
const handleConfirm = async () => {
if (!recordId || !prescriptionData) return;
try {
setIsConfirming(true);
const items = (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((item: any) => ({
...item,
explicitly_confirmed: true
}));
await confirmPrescription(recordId, {
extracted_data: {
...prescriptionData.extracted_data,
items: items
},
items: items
});
setIsConfirmed(true);
} catch (err: any) {
console.error("Confirmation failed", err);
if (err.response?.data?.message) {
alert("Failed: " + err.response.data.message);
} else {
alert("Failed to confirm prescription. Please try again.");
}
} finally {
setIsConfirming(false);
}
};
const handlePrecheck = async () => {
if (!recordId || !prescriptionData) return;
try {
setIsPrechecking(true);
const payload = {
clinical_context: {
patient_id: prescriptionData.extracted_data.patient?.name || prescriptionData.extracted_data.patient_name || 'unknown',
allergies: [],
conditions: []
},
items: (prescriptionData.extracted_data.items || prescriptionData.extracted_data.medicines || []).map((m: any) => {
const name = 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;
if (!qty || isNaN(qty)) {
const doseStr = m.parsed?.dose || m.dosage || '';
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
if (match) {
qty = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
} else {
qty = 1;
}
}
return {
product_id: productId,
qty: qty,
dosage_instructions: m.parsed?.dose || m.dosage || ''
};
})
};
const result = await precheckOrder(recordId, payload);
setPrecheckResult(result);
} catch (err) {
console.error("Precheck failed", err);
alert("Failed to run safety pre-checks.");
} finally {
setIsPrechecking(false);
}
};
const handleDispense = async () => {
if (!recordId || !prescriptionData) return;
try {
setIsDispensing(true);
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 || '';
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;
if (!qty || isNaN(qty)) {
// Try to extract qty from dosage like 1-0-1
const doseStr = m.parsed?.dose || m.dosage || '';
const match = doseStr.match(/(\d+)-(\d+)-(\d+)/);
if (match) {
qty = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
} else {
qty = 1;
}
}
return {
product_id: productId,
qty: qty
};
}),
payment_method: paymentMethod
};
await dispenseOrder(recordId, payload);
setDispenseSuccess(true);
alert("Order successfully dispensed! Stock has been updated.");
} catch (err) {
console.error("Dispense failed", err);
alert("Failed to dispense order. Check inventory levels.");
} finally {
setIsDispensing(false);
}
};
const totalQueueItems = prescriptionsList.length;
const totalQueuePages = Math.max(1, Math.ceil(totalQueueItems / queueItemsPerPage));
const currentQueueData = prescriptionsList.slice(
(queueCurrentPage - 1) * queueItemsPerPage,
queueCurrentPage * queueItemsPerPage
);
return (
<div className="max-w-[1200px] mx-auto flex flex-col w-full p-8">
<header className="mb-8">
<h1>AI Prescription Processing</h1>
<p className="text-slate-500 mt-2">Upload a scanned prescription and let the AI extract the medical details.</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
{/* Upload Column */}
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6 h-fit">
<h2 className="mb-4 text-xl font-bold text-slate-800">Upload Scan</h2>
<div className="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" />
<p className="text-slate-700 font-bold mb-1">Drag and drop your prescription here</p>
<p className="text-sm text-slate-500 mb-6">Supports JPG, PNG, PDF (Max 5MB)</p>
<input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileChange}
accept="image/*,application/pdf"
/>
<label
htmlFor="file-upload"
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
style={{ padding: '10px 24px', display: 'inline-block', lineHeight: '24px' }}
>
Select File
</label>
</div>
{file && (
<div className="mt-6 flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl">
<div className="flex items-center gap-3">
<FileText className="text-blue-500" size={24} />
<div>
<p className="text-sm font-bold text-slate-800">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
{!isUploading && !isPolling && !prescriptionData && (
<button
onClick={handleUpload}
className="bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-bold rounded-lg transition-colors shadow-sm"
style={{ padding: '8px 16px' }}
>
Process with AI
</button>
)}
</div>
)}
{uploadError && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-xl flex items-center gap-3">
<AlertTriangle size={20} />
<p className="text-sm font-bold">{uploadError}</p>
</div>
)}
</div>
{/* Results Column */}
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl p-6">
<h2 className="mb-4 text-xl font-bold text-slate-800">Extraction Results</h2>
{(isUploading || isPolling) && !prescriptionData && (
<div className="h-64 flex flex-col items-center justify-center text-center">
<Loader2 size={40} className="text-blue-500 animate-spin mb-4" />
<p className="text-slate-700 font-bold">AI is analyzing the prescription...</p>
<p className="text-sm text-slate-500 mt-2">Extracting doctor details, medicines, and dosages.</p>
</div>
)}
{!isUploading && !isPolling && !prescriptionData && (
<div className="h-64 flex flex-col items-center justify-center text-center opacity-50">
<FileText size={48} className="text-slate-300 mb-4" />
<p className="text-slate-500 font-bold">No results yet.</p>
<p className="text-sm text-slate-400 font-medium mt-1">Upload a prescription to see AI extraction.</p>
</div>
)}
{prescriptionData && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl 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>
<p className="text-sm font-medium text-emerald-600 mt-1">Please review the details below before confirming for dispense.</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Patient Name</label>
<input
type="text"
value={prescriptionData.extracted_data?.patient?.name || prescriptionData.extracted_data?.patient_name || ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Doctor</label>
<input
type="text"
value={prescriptionData.extracted_data?.prescriber?.name || prescriptionData.extracted_data?.doctor_name || ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Prescribed Medicines</label>
<div className="space-y-2">
{(prescriptionData.extracted_data?.items || prescriptionData.extracted_data?.medicines || []).map((med: any, idx: number) => (
<div key={idx} className="p-3 bg-slate-50 border border-slate-200 rounded-lg flex justify-between items-center">
<span className="font-bold text-slate-800">{med.parsed?.brand || med.name || med.verbatim}</span>
<span className="text-sm font-bold text-blue-600 bg-blue-100/50 px-2.5 py-1 rounded-md">{med.parsed?.dose || med.dosage}</span>
</div>
))}
{!(prescriptionData.extracted_data?.items?.length || prescriptionData.extracted_data?.medicines?.length) && (
<p className="text-sm text-slate-500 italic">No medicines detected.</p>
)}
</div>
</div>
</div>
<div className="pt-6 border-t border-slate-100 flex justify-end">
{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' }}>
<CheckCircle size={18} /> Verified
</button>
{!precheckResult && !dispenseSuccess && (
<button
onClick={handlePrecheck}
disabled={isPrechecking}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center gap-2"
style={{ padding: '10px 24px' }}
>
{isPrechecking ? <Loader2 className="animate-spin" size={18} /> : 'Run Safety Pre-Check'}
</button>
)}
</div>
) : (
<button
onClick={handleConfirm}
disabled={isConfirming}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center gap-2"
style={{ padding: '10px 24px' }}
>
{isConfirming ? <Loader2 className="animate-spin" size={18} /> : 'Confirm & Verify'}
</button>
)}
</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">
{precheckResult.checks?.map((check: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-sm">
{check.passed ? <CheckCircle className="text-emerald-500" size={16} /> : <AlertTriangle className="text-amber-500" size={16} />}
<span className={check.passed ? 'text-slate-600' : 'text-amber-700 font-medium'}>{check.message || check.type}</span>
</div>
))}
{(!precheckResult.checks || precheckResult.checks.length === 0) && (
<div className="flex items-center gap-2 text-sm text-emerald-600 font-medium">
<CheckCircle size={16} /> All clinical safety gates passed.
</div>
)}
</div>
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<div className="flex justify-between items-center mb-4 pb-4 border-b border-slate-100">
<span className="font-bold text-slate-600">Total Bill Amount</span>
<span className="text-xl font-bold text-slate-900">₹{precheckResult.total_amount?.toFixed(2) || '0.00'}</span>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500 mb-2">Payment Method</label>
<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"
>
<option value="cash">Cash</option>
<option value="upi">UPI / Scanner</option>
<option value="card">Credit/Debit Card</option>
</select>
</div>
</div>
<button
onClick={handleDispense}
disabled={isDispensing}
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2"
style={{ padding: '12px 16px' }}
>
{isDispensing ? <Loader2 className="animate-spin" size={18} /> : <><Pill size={18} /> Execute Dispense</>}
</button>
</div>
)}
{dispenseSuccess && (
<div className="mt-6 p-6 bg-emerald-50 border border-emerald-200 rounded-xl text-center animate-in zoom-in-95">
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-3">
<CheckCircle className="text-emerald-500" size={24} />
</div>
<h3 className="font-bold text-emerald-800 text-lg">Order Dispensed Successfully</h3>
<p className="text-emerald-600 mt-1">Inventory has been deducted and audit logs updated.</p>
</div>
)}
</div>
)}
</div>
</div>
<div style={{ padding: '32px 0' }}></div>
{/* Queue Section */}
<div className="bg-white border border-slate-200 shadow-sm rounded-2xl flex flex-col overflow-hidden">
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div>
<h2 className="text-xl font-bold text-slate-800">Prescription Queue</h2>
<p className="text-sm text-slate-500 mt-1">Recent uploads waiting for processing or review.</p>
</div>
<button onClick={fetchPrescriptions} className="bg-white border border-slate-200 text-slate-600 hover:text-blue-600 hover:border-blue-200 hover:bg-blue-50 text-sm font-bold rounded-lg transition-colors shadow-sm" style={{ padding: '8px 16px', lineHeight: '20px' }}>
Refresh Queue
</button>
</div>
<div className="overflow-x-auto p-0">
{prescriptionsList.length === 0 ? (
<div className="p-16 text-center text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p className="font-medium text-slate-600">No prescriptions found in queue.</p>
</div>
) : (
<Table
data={currentQueueData}
columns={queueColumns}
getRowId={(item) => item.id}
/>
)}
</div>
{totalQueueItems > 0 && (
<div className="border-t border-slate-100 bg-slate-50/30">
<Pagination
currentPage={queueCurrentPage}
totalPages={totalQueuePages}
totalItems={totalQueueItems}
itemsPerPage={queueItemsPerPage}
itemsPerPageOptions={[5, 10, 25, 50]}
onPageChange={(page) => setQueueCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setQueueItemsPerPage(limit);
setQueueCurrentPage(1);
}}
/>
</div>
)}
</div>
</div>
);
};
export default Prescriptions;
+209
View File
@@ -0,0 +1,209 @@
import React, { useState, useEffect, useMemo } from 'react';
import { BookOpen, Search, AlertTriangle, Clock } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { Table } from '../components/Table';
import Pagination from '../components/Pagination';
import { getH1Register } from '../services/api';
interface H1RegisterRecord {
id: string;
dispensing_id: string;
patient_name: string;
prescriber_name: string;
drug_name: string;
batch_no: string;
qty: number;
created_at: string;
}
const columnHelper = createColumnHelper<H1RegisterRecord>();
const columns = [
columnHelper.accessor('id', {
header: 'ENTRY ID',
size: 100,
cell: info => (
<span className="font-mono text-[12px] bg-slate-100 px-2 py-1 rounded text-slate-700 font-bold">
{info.getValue()}
</span>
)
}),
columnHelper.accessor('created_at', {
header: 'DATE',
size: 150,
cell: info => {
const date = new Date(info.getValue());
return <span className="text-slate-600 font-medium text-[13px]">{date.toLocaleString()}</span>;
}
}),
columnHelper.accessor('patient_name', {
header: 'PATIENT NAME',
size: 180,
cell: info => <span className="font-bold text-slate-800 text-[14px]">{info.getValue()}</span>
}),
columnHelper.accessor('prescriber_name', {
header: 'PRESCRIBER',
size: 180,
cell: info => <span className="text-slate-700 text-[13px]">{info.getValue()}</span>
}),
columnHelper.accessor('drug_name', {
header: 'DRUG (GENERIC)',
size: 250,
cell: info => <span className="font-bold text-slate-800 text-[13px]">{info.getValue()}</span>
}),
columnHelper.accessor('batch_no', {
header: 'BATCH',
size: 100,
cell: info => (
<span className="text-[11px] font-bold text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded uppercase bg-slate-50">
{info.getValue()}
</span>
)
}),
columnHelper.accessor('qty', {
header: 'QTY',
size: 80,
cell: info => <span className="font-bold text-slate-800">{info.getValue()}</span>
}),
];
const Registers = () => {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [records, setRecords] = useState<H1RegisterRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchRecords();
}, []);
const fetchRecords = async () => {
try {
setIsLoading(true);
setError(null);
const data = await getH1Register();
if (data && data.records) {
setRecords(data.records);
}
} catch (err) {
console.error("Failed to load H1 register", err);
setError("Failed to load statutory registers.");
} finally {
setIsLoading(false);
}
};
const filteredRecords = useMemo(() => {
return records.filter(item =>
item.patient_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.drug_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.prescriber_name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const totalItems = filteredRecords.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const currentData = filteredRecords.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return (
<div className="max-w-[1400px] mx-auto flex flex-col w-full">
<header className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="p-2.5 bg-purple-100 text-purple-600 rounded-xl">
<BookOpen size={22} />
</div>
<h1 className="text-2xl font-bold text-slate-800">Statutory Registers</h1>
</div>
<p className="text-slate-500 mt-1 ml-12">View Schedule H1 and other compliant pharmacy registers.</p>
</div>
<div className="flex gap-2">
<button className="bg-slate-800 hover:bg-slate-900 text-white font-medium rounded-lg shadow-sm transition-colors text-sm" style={{ padding: '8px 20px', lineHeight: '20px' }}>
Export CSV
</button>
</div>
</header>
<div className="flex flex-col md:flex-row items-center justify-between gap-4" style={{ marginBottom: '40px', marginTop: '16px' }}>
<div className="relative w-full max-w-2xl group">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none transition-colors text-slate-400 group-focus-within:text-blue-500" style={{ paddingLeft: '16px' }}>
<Search size={20} strokeWidth={2} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by patient, drug, or doctor..."
className="w-full py-3 bg-slate-50 border border-slate-200 hover:border-slate-300 hover:bg-slate-100/50 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 shadow-sm placeholder:text-slate-400 transition-all text-slate-800"
style={{ paddingLeft: '44px', paddingRight: '16px', fontSize: '15px' }}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-500">Register:</span>
<select className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="h1">Schedule H1 Register</option>
<option value="sales" disabled>Sales Register (Coming Soon)</option>
</select>
</div>
</div>
<div className="w-full bg-white border border-slate-200 rounded-2xl shadow-sm flex flex-col overflow-hidden">
<div className="overflow-x-auto">
{error ? (
<div className="p-8 text-center text-red-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={fetchRecords}
className="mt-4 px-4 py-2 bg-purple-50 text-purple-600 rounded-md hover:bg-purple-100 transition-colors font-medium"
>
Try Again
</button>
</div>
) : isLoading ? (
<div className="p-8 flex flex-col items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-4 border-slate-200 border-t-purple-600 rounded-full animate-spin mb-4"></div>
<p className="text-slate-500 font-medium">Loading H1 register...</p>
</div>
) : records.length === 0 ? (
<div className="p-16 text-center text-slate-500 flex flex-col items-center min-h-[400px] justify-center">
<Clock className="w-16 h-16 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-medium text-slate-700 mb-2">No H1 Entries Found</h3>
<p className="max-w-md mx-auto">When Schedule H1 drugs are dispensed, they will automatically appear in this immutable register.</p>
</div>
) : (
<Table
data={currentData}
columns={columns}
getRowId={(item) => item.id}
/>
)}
</div>
{totalItems > 0 && !isLoading && !error && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onPageChange={(page) => setCurrentPage(page)}
onItemsPerPageChange={(limit) => {
setItemsPerPage(limit);
setCurrentPage(1);
}}
/>
)}
</div>
</div>
);
};
export default Registers;
+171
View File
@@ -0,0 +1,171 @@
import axios from 'axios';
// Create a base Axios instance for API calls
export const api = axios.create({
baseURL: 'http://localhost:8080/v1', // Backend port is 8080
headers: {
'Content-Type': 'application/json',
},
});
// Authenticate and retrieve token
export const authenticate = async () => {
try {
const response = await axios.post('http://localhost:8080/v1/auth/token', {
grant_type: 'client_credentials',
client_id: 'jayam_web_ai',
client_secret: 'my_secret_key'
});
// Assuming the response contains an access_token field
const token = response.data?.access_token || response.data?.token;
if (token) {
localStorage.setItem('auth_token', token);
}
return response.data;
} catch (error) {
console.error('Authentication failed:', error);
throw error;
}
};
// Request interceptor to automatically add the auth token to headers
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
if (config.headers) {
config.headers['X-Actor-Id'] = localStorage.getItem('actor_id') || 'act_admin';
}
return config;
}, (error) => {
return Promise.reject(error);
});
export const getDrugs = async () => {
const response = await api.get('/drugs');
return response.data;
};
export const addMedicine = async (data: any) => {
const response = await api.post('/drugs', data);
return response.data;
};
export const getDrugSearch = async (query: string) => {
const response = await api.get('/inventory/expiring?days=3650');
return response.data;
};
export const getExpiringInventory = async (days: number = 3650) => {
const response = await api.get(`/inventory/expiring?days=${days}`);
return response.data;
};
export const getLowStockInventory = async (threshold: number = 50) => {
const response = await api.get(`/inventory/low-stock?threshold=${threshold}`);
return response.data;
};
export const addStock = async (payload: any) => {
const idempotencyKey = `idemp-${Date.now()}-${Math.random().toString(36).substring(7)}`;
const response = await api.post('/inventory/grn', payload, {
headers: {
'Idempotency-Key': idempotencyKey
}
});
return response.data;
};
export const getAuditLogs = async (limit: number = 10) => {
const response = await api.get(`/audit/ai-calls?limit=${limit}`);
return response.data;
};
// --- AI Voice APIs ---
export const sendVoiceCommand = async (transcript: string) => {
const response = await api.post('/ai/voice', { transcript });
return response.data;
};
// --- Catalogue APIs ---
export const searchDrugs = async (q: string, form?: string, limit: number = 10) => {
const params = new URLSearchParams({ q, limit: limit.toString() });
if (form) params.append('form', form);
const response = await api.get(`/drugs/search?${params.toString()}`);
return response.data;
};
export const getProductBatches = async (productId: string) => {
const response = await api.get(`/products/${productId}/batches`);
return response.data;
};
// --- Prescription APIs ---
export const getAllPrescriptions = async () => {
const response = await api.get('/rx');
return response.data;
};
export const uploadPrescription = async (file: File) => {
const formData = new FormData();
formData.append('image', file);
// Note: We don't set Content-Type header manually for FormData, Axios handles the boundary automatically
const response = await api.post('/rx', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
};
export const getPrescriptionDetails = async (id: string) => {
const response = await api.get(`/rx/${id}`);
return response.data;
};
export const confirmPrescription = async (id: string, data: any) => {
const response = await api.post(`/rx/${id}/confirm`, data);
return response.data;
};
// --- Dispensing APIs ---
export const createOrderDraft = async (data: any) => {
const response = await api.post('/orders', data);
return response.data;
};
export const updateOrderItems = async (id: string, items: any[]) => {
const response = await api.patch(`/orders/${id}/items`, { items });
return response.data;
};
export const precheckOrder = async (id: string, data: any) => {
const response = await api.post(`/orders/${id}/precheck`, data);
return response.data;
};
export const dispenseOrder = async (id: string, data: any) => {
const idempotencyKey = `idemp-dispense-${Date.now()}-${Math.random().toString(36).substring(7)}`;
const response = await api.post(`/orders/${id}/dispense`, data, {
headers: {
'Idempotency-Key': idempotencyKey
}
});
return response.data;
};
export const getDispensingHistory = async () => {
const response = await api.get('/orders/history');
return response.data;
};
export const getH1Register = async () => {
const response = await api.get('/orders/h1-register');
return response.data;
};
export default api;
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})