for migration to new repo to be able to use the CICD
All checks were successful
Build and Deploy LSFE Frontend / build-and-deploy (push) Successful in 4m36s
All checks were successful
Build and Deploy LSFE Frontend / build-and-deploy (push) Successful in 4m36s
This commit is contained in:
commit
f568a8aa14
17
.eslintrc.json
Normal file
17
.eslintrc.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest",
|
||||
"@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
137
.gitea/workflows/deploy.yml
Normal file
137
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,137 @@
|
||||
name: Build and Deploy LSFE Frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: lsfe-server
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Clean previous build output
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force ".\dist" -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Install dependencies
|
||||
shell: pwsh
|
||||
run: npm ci
|
||||
|
||||
# Uses the committed .env (e.g. .env.production) picked up automatically by Vite -
|
||||
# no secrets injected at build time for this project.
|
||||
- name: Build (Vite production build)
|
||||
shell: pwsh
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build output exists
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path ".\dist\index.html")) {
|
||||
throw "Vite build did not produce dist\index.html - aborting before touching live site"
|
||||
}
|
||||
|
||||
# ---- Backup current live deployment before touching anything ----
|
||||
- name: Backup current live files
|
||||
shell: pwsh
|
||||
run: |
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
New-Item -ItemType Directory -Force -Path "C:\backups-frontend\$stamp" | Out-Null
|
||||
|
||||
if (Test-Path "C:\inetpub\LSFE-Frontend") {
|
||||
robocopy "C:\inetpub\LSFE-Frontend" "C:\backups-frontend\$stamp\frontend" /MIR /R:2 /W:3 | Out-Null
|
||||
}
|
||||
|
||||
$stamp | Out-File -FilePath "C:\backups-frontend\latest.txt" -Encoding ascii -NoNewline
|
||||
|
||||
# Keep only the last 5 backups to avoid filling the disk
|
||||
$all = Get-ChildItem "C:\backups-frontend" -Directory | Sort-Object Name -Descending
|
||||
if ($all.Count -gt 5) {
|
||||
$all | Select-Object -Skip 5 | Remove-Item -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Host "Backed up current frontend deployment to C:\backups-frontend\$stamp"
|
||||
exit 0
|
||||
|
||||
- name: Stop app pool
|
||||
shell: pwsh
|
||||
run: |
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name "LSFE-Frontend" -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
- name: Deploy frontend files
|
||||
id: deploy_frontend
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Mirrors the built dist folder into the live IIS site path.
|
||||
# No content/uploads folder to protect here (that's a backend concept) -
|
||||
# if you later add one under the frontend site, add /XD <folder> here too.
|
||||
robocopy ".\dist" "C:\inetpub\LSFE-Frontend" /MIR /R:3 /W:5
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host "ROBOCOPY EXIT CODE: $rc"
|
||||
if ($rc -ge 8) {
|
||||
throw "robocopy failed for frontend with exit code $rc"
|
||||
}
|
||||
exit 0
|
||||
|
||||
- name: Start app pool
|
||||
shell: pwsh
|
||||
run: |
|
||||
Import-Module WebAdministration
|
||||
Start-WebAppPool -Name "LSFE-Frontend"
|
||||
|
||||
- name: Verify app pool is running
|
||||
shell: pwsh
|
||||
run: |
|
||||
Start-Sleep -Seconds 3
|
||||
Import-Module WebAdministration
|
||||
$fe = Get-WebAppPoolState -Name "LSFE-Frontend"
|
||||
Write-Host "LSFE-Frontend: $($fe.Value)"
|
||||
if ($fe.Value -ne "Started") {
|
||||
throw "Frontend app pool failed to start"
|
||||
}
|
||||
|
||||
# ---- Rollback path: only runs if any prior step in this job failed ----
|
||||
- name: ROLLBACK - restore previous backup
|
||||
if: failure()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$stamp = (Get-Content "C:\backups-frontend\latest.txt" -Raw).Trim()
|
||||
$backupPath = "C:\backups-frontend\$stamp"
|
||||
Write-Host "Deployment failed - rolling back to backup: $backupPath"
|
||||
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name "LSFE-Frontend" -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
if (Test-Path "$backupPath\frontend") {
|
||||
robocopy "$backupPath\frontend" "C:\inetpub\LSFE-Frontend" /MIR /R:3 /W:5 | Out-Null
|
||||
}
|
||||
|
||||
Start-WebAppPool -Name "LSFE-Frontend"
|
||||
|
||||
Write-Host "Rollback complete. Restored from $backupPath"
|
||||
exit 0
|
||||
|
||||
- name: ROLLBACK - verify pool after restore
|
||||
if: failure()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Start-Sleep -Seconds 3
|
||||
Import-Module WebAdministration
|
||||
$fe = Get-WebAppPoolState -Name "LSFE-Frontend"
|
||||
|
||||
Write-Host "After rollback - LSFE-Frontend: $($fe.Value)"
|
||||
|
||||
if ($fe.Value -ne "Started") {
|
||||
Write-Host "WARNING: frontend app pool still not running after rollback. Manual intervention needed."
|
||||
}
|
||||
98
.gitignore
vendored
Normal file
98
.gitignore
vendored
Normal file
@ -0,0 +1,98 @@
|
||||
# =========================
|
||||
# Dependencies
|
||||
# =========================
|
||||
node_modules/
|
||||
|
||||
# Package manager logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# =========================
|
||||
# Build output
|
||||
# =========================
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# =========================
|
||||
# Vite
|
||||
# =========================
|
||||
.vite/
|
||||
|
||||
# =========================
|
||||
# TypeScript
|
||||
# =========================
|
||||
*.tsbuildinfo
|
||||
|
||||
# =========================
|
||||
# Environment variables
|
||||
# =========================
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Keep example env file
|
||||
!.env.example
|
||||
|
||||
# =========================
|
||||
# IDEs
|
||||
# =========================
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# =========================
|
||||
# OS files
|
||||
# =========================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# =========================
|
||||
# Logs
|
||||
# =========================
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# =========================
|
||||
# Temporary files
|
||||
# =========================
|
||||
tmp/
|
||||
temp/
|
||||
.cache/
|
||||
.eslintcache
|
||||
|
||||
# =========================
|
||||
# Testing
|
||||
# =========================
|
||||
playwright-report/
|
||||
test-results/
|
||||
coverage/
|
||||
|
||||
# =========================
|
||||
# Misc
|
||||
# =========================
|
||||
*.tgz
|
||||
*.zip
|
||||
*.7z
|
||||
|
||||
# =========================
|
||||
# Local database files
|
||||
# (if used for development)
|
||||
# =========================
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# =========================
|
||||
# Runtime PID files
|
||||
# =========================
|
||||
*.pid
|
||||
*.pid.lock
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Introduction
|
||||
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
|
||||
|
||||
# Getting Started
|
||||
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
|
||||
1. Installation process
|
||||
2. Software dependencies
|
||||
3. Latest releases
|
||||
4. API references
|
||||
|
||||
# Build and Test
|
||||
TODO: Describe and show how to build your code and run the tests.
|
||||
|
||||
# Contribute
|
||||
TODO: Explain how other users and developers can contribute to make your code better.
|
||||
|
||||
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
|
||||
- [ASP.NET Core](https://github.com/aspnet/Home)
|
||||
- [Visual Studio Code](https://github.com/Microsoft/vscode)
|
||||
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
29
index.html
Normal file
29
index.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<!-- Multiple favicon sizes for better clarity -->
|
||||
<link rel="icon" id="favicon" type="image/svg+xml" href="/vamsler.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/vamsler-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/vamsler-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/vamsler-180.png" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vamsler + Ultramed</title>
|
||||
|
||||
<script>
|
||||
// Alternate between logos every 3.5 seconds
|
||||
let useVamsler = true;
|
||||
setInterval(() => {
|
||||
const favicon = document.getElementById('favicon');
|
||||
favicon.href = useVamsler ? '/ultramed.svg' : '/vamsler.svg';
|
||||
useVamsler = !useVamsler;
|
||||
}, 3500);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5903
package-lock.json
generated
Normal file
5903
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "l-sfe",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@tanstack/react-query": "^5.89.0",
|
||||
"@tiptap/react": "^3.4.4",
|
||||
"@tiptap/starter-kit": "^3.4.4",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.16",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"recharts": "^3.2.1",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.8.31",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"vite": "^7.1.6"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
865
public/favicon.svg
Normal file
865
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 472 KiB |
392
public/ultramed.svg
Normal file
392
public/ultramed.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 273 KiB |
865
public/vamsler.svg
Normal file
865
public/vamsler.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 472 KiB |
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
34
public/web.config
Normal file
34
public/web.config
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<!-- MIME types -->
|
||||
<staticContent>
|
||||
<remove fileExtension=".woff2" />
|
||||
<mimeMap fileExtension=".woff2" mimeType="font/woff2" />
|
||||
<remove fileExtension=".wasm" />
|
||||
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||
</staticContent>
|
||||
|
||||
<!-- React SPA routing -->
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="ReactRouter" stopProcessing="true">
|
||||
<match url=".*" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="/index.html" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
|
||||
<!-- Default document -->
|
||||
<defaultDocument>
|
||||
<files>
|
||||
<clear />
|
||||
<add value="index.html" />
|
||||
</files>
|
||||
</defaultDocument>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
48
src/App.css
Normal file
48
src/App.css
Normal file
@ -0,0 +1,48 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* Import Google Fonts */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap");
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all elements */
|
||||
* {
|
||||
transition-property:
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke,
|
||||
opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Custom component styles */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.teal-gradient {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #134e4a 100%);
|
||||
}
|
||||
127
src/App.tsx
Normal file
127
src/App.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
// Import your existing components
|
||||
import LoginPage from './components/features/auth/LoginPage';
|
||||
import { ToastContainer } from './components/ui/Toast';
|
||||
import { Layout } from './components/layout';
|
||||
import { DashboardOverview, SettingsPage } from './components/dashboard';
|
||||
import { PatientsPage } from './pages/doctor';
|
||||
|
||||
// Import your existing DoctorManagementPage
|
||||
import { DoctorManagement } from './pages/doctor/DoctorManagement';
|
||||
import { AppointmentManagement } from './pages/appointment/AppointmentManagement';
|
||||
import { ReportsPage } from './pages/tabReports/ReportsPage';
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
// Public Route Component
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? <Navigate to="/dashboard" replace /> : <>{children}</>;
|
||||
};
|
||||
|
||||
// Main Dashboard Layout
|
||||
const DashboardLayout: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = React.useState('dashboard');
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'dashboard':
|
||||
return <DashboardOverview user={user} onLogout={logout} />;
|
||||
case 'doctors':
|
||||
return <DoctorManagement />;
|
||||
case 'patients':
|
||||
return <PatientsPage />;
|
||||
case 'appointments':
|
||||
return <AppointmentManagement />;
|
||||
case 'reports':
|
||||
return <ReportsPage user={user} />;
|
||||
case 'settings':
|
||||
return <SettingsPage user={user} onLogout={logout} />;
|
||||
default:
|
||||
return <DashboardOverview user={user} onLogout={logout} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
>
|
||||
{renderContent()}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
{/* Catch all route */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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 |
94
src/components/appointment/AppointmentCard.tsx
Normal file
94
src/components/appointment/AppointmentCard.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { Appointment } from "@/types/appointment/appointment";
|
||||
import { Clock, Edit, Eye, Plus, Trash2, User } from "lucide-react";
|
||||
|
||||
export const AppointmentCard: React.FC<{
|
||||
appointment: Appointment;
|
||||
onAdd: () => void;
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
showDelete?: boolean;
|
||||
isPlanning: boolean;
|
||||
showAdd?: boolean;
|
||||
}> = ({ appointment, onAdd, onView, onEdit, onDelete, showDelete = false, isPlanning, showAdd = true }) => {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const normalized = (status || '').toLowerCase().trim();
|
||||
switch (normalized) {
|
||||
case 'partial': return 'bg-blue-100 text-blue-800';
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'none': return 'bg-gray-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{/* {appointment.startTime} - {appointment.endTime}*/}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(appointment.status)}`}>
|
||||
{appointment.status.charAt(0).toUpperCase() + appointment.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-900">{appointment.firstName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Dr. {appointment.lastName}</p>
|
||||
<p className="text-xs text-gray-500">M.R. {appointment.mrFullName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4 pt-3 border-t border-gray-100">
|
||||
{/* Show Add button only when isPlanning is true AND showAdd is true */}
|
||||
{isPlanning && showAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Add Appointment"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show View button only when isPlanning is true */}
|
||||
{isPlanning && (
|
||||
<button
|
||||
onClick={onView}
|
||||
className="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Edit and Delete buttons only when showDelete is true AND isPlanning is true */}
|
||||
{showDelete && isPlanning && (
|
||||
<>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
854
src/components/appointment/AppointmentForm.tsx
Normal file
854
src/components/appointment/AppointmentForm.tsx
Normal file
@ -0,0 +1,854 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Checkbox, Modal, RadioGroup } from "../ui/";
|
||||
import { Appointment, AppointmentFormData, SelectedPromo, SelectedSample } from "@/types/appointment/appointment";
|
||||
import { Input } from "../ui";
|
||||
import { useProductApi } from "../../hooks/generic/useProduct";
|
||||
import { useInventoryApi } from "../../hooks/generic/useInventory";
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
export const AppointmentForm: React.FC<{
|
||||
appointment?: Appointment;
|
||||
onSubmit: (data: AppointmentFormData) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
mode: 'add' | 'edit' | 'view' | 'delete';
|
||||
// Add these new props for existing data
|
||||
existingSamples?: SelectedSample[];
|
||||
existingPromos?: SelectedPromo[];
|
||||
currentCalendarDate?: Date;
|
||||
}> = ({
|
||||
appointment,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
mode,
|
||||
existingSamples = [],
|
||||
existingPromos = [],
|
||||
currentCalendarDate
|
||||
}) => {
|
||||
|
||||
|
||||
const [formData, setFormData] = useState<AppointmentFormData>({
|
||||
doctorId: appointment?.doctorId || 0,
|
||||
lastName: appointment?.lastName || '',
|
||||
mrPlanDate: appointment?.mrPlanDate || appointment?.callDate || '',
|
||||
mrRawPlanId: appointment?.mrRawPlanId || '',
|
||||
callDate: appointment?.callDate || '',
|
||||
productId: appointment?.productId || 0,
|
||||
callType: appointment?.callType || '',
|
||||
slp: appointment?.slp || '',
|
||||
isLiterature: appointment?.isLiterature || false,
|
||||
isJoinCall: appointment?.isJoinCall || false,
|
||||
mrPlanDetailId: appointment?.mrPlanDetailId || '',
|
||||
isUpdateDelete: appointment?.isUpdateDelete || 0,
|
||||
firstName: appointment?.firstName || '',
|
||||
middleInitial: appointment?.middleInitial || '',
|
||||
mrFullName: appointment?.mrFullName || '',
|
||||
specializationName: appointment?.specializationName || '',
|
||||
institutionName: appointment?.institutionName || '',
|
||||
divisionName: appointment?.divisionName || '',
|
||||
territoryName: appointment?.territoryName || '',
|
||||
maxVisit: appointment?.maxVisit || 0,
|
||||
isCatchUpCall: appointment?.isCatchUpCall || false,
|
||||
status: appointment?.status || 'partial'
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const { products, setProducts, fetchProducts } = useProductApi();
|
||||
const { inventory, fetchInventory } = useInventoryApi();
|
||||
|
||||
const { addToast } = useToast();
|
||||
// Modal states
|
||||
const [isSamplesModalOpen, setIsSamplesModalOpen] = useState(false);
|
||||
const [isPromosModalOpen, setIsPromosModalOpen] = useState(false);
|
||||
|
||||
// Selected items states - Initialize with existing data
|
||||
const [selectedSamples, setSelectedSamples] = useState<SelectedSample[]>(existingSamples);
|
||||
const [selectedPromos, setSelectedPromos] = useState<SelectedPromo[]>(existingPromos);
|
||||
|
||||
// Temporary selections in modal (before clicking Add)
|
||||
const [tempSelectedSampleIds, setTempSelectedSampleIds] = useState<Set<string>>(new Set());
|
||||
const [tempSelectedPromoIds, setTempSelectedPromoIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Search states for modals
|
||||
const [samplesSearchTerm, setSamplesSearchTerm] = useState('');
|
||||
const [promosSearchTerm, setPromosSearchTerm] = useState('');
|
||||
|
||||
// Only initialize once when the form opens, not on every change
|
||||
useEffect(() => {
|
||||
// Only set if we haven't already initialized and we have existing data
|
||||
if (mode === 'edit' && existingSamples.length > 0 && selectedSamples.length === 0) {
|
||||
setSelectedSamples(existingSamples);
|
||||
} else if (mode === 'add') {
|
||||
// Clear for add mode
|
||||
setSelectedSamples([]);
|
||||
}
|
||||
}, [mode]); // Only run when mode changes, NOT when existingSamples changes
|
||||
|
||||
useEffect(() => {
|
||||
// Only set if we haven't already initialized and we have existing data
|
||||
if (mode === 'edit' && existingPromos.length > 0 && selectedPromos.length === 0) {
|
||||
setSelectedPromos(existingPromos);
|
||||
} else if (mode === 'add') {
|
||||
// Clear for add mode
|
||||
setSelectedPromos([]);
|
||||
}
|
||||
}, [mode]); // Only run when mode changes, NOT when existingPromos changes
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (appointment?.productId && appointment.productName) {
|
||||
setProducts([{
|
||||
productId: appointment.productId,
|
||||
productName: appointment.productName,
|
||||
productLink: appointment.productName,
|
||||
}]);
|
||||
}
|
||||
}, [appointment, setProducts]);
|
||||
|
||||
// Get the relevant date based on mode and extract month
|
||||
const getFilterMonth = (): number | null => {
|
||||
const relevantDate = mode === 'edit' ? formData.mrPlanDate : formData.callDate;
|
||||
if (!relevantDate) return null;
|
||||
|
||||
const date = new Date(relevantDate);
|
||||
return date.getMonth() + 1;
|
||||
};
|
||||
|
||||
// Check if date is selected
|
||||
const isDateSelected = mode === 'edit' ? !!formData.mrPlanDate : !!formData.callDate;
|
||||
|
||||
const updateLiterature = (value: string) => {
|
||||
setFormData(prev => ({ ...prev, isLiterature: value === 'true' }));
|
||||
if (errors.isLiterature) {
|
||||
setErrors(prev => ({ ...prev, isLiterature: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.callDate) {
|
||||
newErrors.callDate = 'Appointment date is required';
|
||||
}
|
||||
|
||||
if (!formData.mrPlanDate) {
|
||||
formData.mrPlanDate = formData.callDate;
|
||||
}
|
||||
|
||||
// ✅ NEW VALIDATION: Check if callDate is valid in edit mode
|
||||
if (mode === 'edit' && formData.mrPlanDate && formData.callDate) {
|
||||
const validation = isValidAppointmentDate(formData.mrPlanDate, formData.callDate);
|
||||
|
||||
if (!validation.valid) {
|
||||
newErrors.callDate = validation.message;
|
||||
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Invalid Date Selection',
|
||||
message: validation.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const computedSLP = [
|
||||
selectedSamples.length ? 'Samples' : '',
|
||||
selectedPromos.length ? 'Promotional Materials' : '',
|
||||
formData.isLiterature ? 'Literature' : ''
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
if (selectedPromos.length === 0) {
|
||||
newErrors.selectedPromos = '';
|
||||
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Missing Promotional Material',
|
||||
message: 'Please add at least one promotional material before saving.'
|
||||
});
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSubmit: AppointmentFormData = {
|
||||
...formData,
|
||||
selectedSamples,
|
||||
selectedPromos,
|
||||
slp: computedSLP
|
||||
};
|
||||
|
||||
onSubmit(dataToSubmit);
|
||||
};
|
||||
|
||||
const updateField = (field: keyof typeof formData) => (value: string | number) => {
|
||||
let processedValue = value;
|
||||
|
||||
if (field === 'productId' || field === 'maxVisit' || field === 'isUpdateDelete') {
|
||||
processedValue = Number(value);
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: processedValue
|
||||
}));
|
||||
|
||||
// Real-time validation for callDate in edit mode
|
||||
if (field === 'callDate' && mode === 'edit' && formData.mrPlanDate && value) {
|
||||
const validation = isValidAppointmentDate(formData.mrPlanDate, value as string);
|
||||
|
||||
if (!validation.valid) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
callDate: validation.message
|
||||
}));
|
||||
return; // Don't clear error
|
||||
}
|
||||
}
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Samples Modal Handlers
|
||||
const handleSampleCheckbox = (inventoryId: string, checked: boolean) => {
|
||||
|
||||
setTempSelectedSampleIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(inventoryId);
|
||||
} else {
|
||||
newSet.delete(inventoryId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSamples = () => {
|
||||
const newSamples = monthFilteredInventory
|
||||
.filter(item => tempSelectedSampleIds.has(item.inventoryId))
|
||||
.map(item => ({
|
||||
inventoryId: item.inventoryId,
|
||||
description: item.description,
|
||||
qtyBalance: item.qtyIn - item.qtyOut,
|
||||
selectedQty: 1,
|
||||
maxQty: item.qtyIn - item.qtyOut, // For add mode, max = balance
|
||||
originalQty: 0 // No original qty in add mode
|
||||
}));
|
||||
|
||||
if (newSamples.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSamples(prev => {
|
||||
const existing = prev.filter(s => !tempSelectedSampleIds.has(s.inventoryId));
|
||||
return [...existing, ...newSamples];
|
||||
});
|
||||
|
||||
setTempSelectedSampleIds(new Set());
|
||||
setSamplesSearchTerm('');
|
||||
setIsSamplesModalOpen(false);
|
||||
};
|
||||
|
||||
const handleAddPromos = () => {
|
||||
|
||||
const newPromos = products
|
||||
.filter(item => {
|
||||
const hasId = tempSelectedPromoIds.has(item.productId);
|
||||
return hasId;
|
||||
})
|
||||
.map(item => ({
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
products: item.productName,
|
||||
}));
|
||||
|
||||
setSelectedPromos(prev => {
|
||||
const existing = prev.filter(p => !tempSelectedPromoIds.has(p.productId));
|
||||
const updated = [...existing, ...newPromos];
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Clear temp state AFTER state update
|
||||
setTimeout(() => {
|
||||
setTempSelectedPromoIds(new Set());
|
||||
setIsPromosModalOpen(false);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleRemoveSample = (inventoryId: string) => {
|
||||
setSelectedSamples(prev => prev.filter(s => s.inventoryId !== inventoryId));
|
||||
};
|
||||
|
||||
const handleSampleQtyChange = (inventoryId: string, qty: number) => {
|
||||
setSelectedSamples(prev =>
|
||||
prev.map(s => s.inventoryId === inventoryId ? { ...s, selectedQty: qty } : s)
|
||||
);
|
||||
};
|
||||
|
||||
// Promos Modal Handlers
|
||||
const handlePromoCheckbox = (productId: number, checked: boolean) => {
|
||||
setTempSelectedPromoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(productId);
|
||||
} else {
|
||||
newSet.delete(productId);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemovePromo = (productId: number) => {
|
||||
setSelectedPromos(prev => prev.filter(p => p.productId !== productId));
|
||||
};
|
||||
|
||||
// Filter inventory based on search
|
||||
const filteredInventory = inventory.filter(item => {
|
||||
if (!samplesSearchTerm) return true;
|
||||
const searchLower = samplesSearchTerm.toLowerCase();
|
||||
return item.description.toLowerCase().includes(searchLower);
|
||||
});
|
||||
|
||||
// Filter products based on search
|
||||
const filteredProducts = products.filter(item => {
|
||||
if (!promosSearchTerm) return true;
|
||||
const searchLower = promosSearchTerm.toLowerCase();
|
||||
return item.productName.toLowerCase().includes(searchLower);
|
||||
});
|
||||
|
||||
// Filter inventory by month
|
||||
const monthFilteredInventory = filteredInventory.filter(item => {
|
||||
const filterMonth = getFilterMonth();
|
||||
if (filterMonth === null) return false;
|
||||
return item.cycleMonth === filterMonth;
|
||||
});
|
||||
|
||||
|
||||
// Updated helper function - Week starts on MONDAY
|
||||
const isValidAppointmentDate = (previousDate: string, newDate: string): { valid: boolean; message: string } => {
|
||||
if (!previousDate || !newDate) {
|
||||
return { valid: false, message: 'Both dates are required' };
|
||||
}
|
||||
|
||||
const prevDate = new Date(previousDate);
|
||||
const callDate = new Date(newDate);
|
||||
|
||||
// Get the start of the week (MONDAY) for previousDate
|
||||
const weekStart = new Date(prevDate);
|
||||
const dayOfWeek = prevDate.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
|
||||
// Calculate days to subtract to get to Monday
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // If Sunday (0), go back 6 days; otherwise dayOfWeek - 1
|
||||
|
||||
weekStart.setDate(prevDate.getDate() - daysToMonday);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// Get the end of the week (SUNDAY)
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6); // Monday + 6 days = Sunday
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Check if newDate is within the same week
|
||||
const isWithinWeek = callDate >= weekStart && callDate <= weekEnd;
|
||||
|
||||
if (!isWithinWeek) {
|
||||
const weekStartStr = weekStart.toISOString().split('T')[0];
|
||||
const weekEndStr = weekEnd.toISOString().split('T')[0];
|
||||
return {
|
||||
valid: false,
|
||||
message: `New appointment date must be within the same week as previous date (${weekStartStr} to ${weekEndStr})`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
};
|
||||
|
||||
// Calculate min and max dates based on current calendar month
|
||||
const getDateConstraints = () => {
|
||||
if (!currentCalendarDate) {
|
||||
console.log('⚠️ getDateConstraints: currentCalendarDate is undefined!');
|
||||
return { min: undefined, max: undefined };
|
||||
}
|
||||
|
||||
const year = currentCalendarDate.getFullYear();
|
||||
const month = currentCalendarDate.getMonth();
|
||||
|
||||
// Format as YYYY-MM-DD in local timezone
|
||||
const minDateStr = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
|
||||
// Last day of the month - get the actual last day number
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
// Format as YYYY-MM-DD in local timezone
|
||||
const maxDateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
return { min: minDateStr, max: maxDateStr };
|
||||
};
|
||||
const dateConstraints = getDateConstraints();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
||||
Appointment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Doctor Name"
|
||||
value={`${formData.firstName} ${formData.lastName}`}
|
||||
onChange={updateField('firstName')}
|
||||
readOnly
|
||||
required
|
||||
error={errors.firstName}
|
||||
/>
|
||||
<Input
|
||||
label="Specialization"
|
||||
value={formData.specializationName}
|
||||
onChange={updateField('specializationName')}
|
||||
readOnly
|
||||
required
|
||||
error={errors.specializationName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Institution"
|
||||
value={formData.institutionName}
|
||||
onChange={updateField('institutionName')}
|
||||
readOnly
|
||||
required
|
||||
error={errors.specializationName}
|
||||
/>
|
||||
<Input
|
||||
label="Territory"
|
||||
value={formData.territoryName}
|
||||
onChange={updateField('territoryName')}
|
||||
readOnly
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Status"
|
||||
value={formData.status}
|
||||
onChange={updateField('status')}
|
||||
readOnly
|
||||
type="text"
|
||||
error={errors.status}
|
||||
/>
|
||||
<RadioGroup
|
||||
label="Literatures"
|
||||
value={formData.isLiterature}
|
||||
onChange={updateLiterature}
|
||||
required
|
||||
error={errors.isLiterature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{mode === 'edit' && (
|
||||
<Input
|
||||
label="Previous Date"
|
||||
type="date"
|
||||
value={formData.mrPlanDate}
|
||||
onChange={updateField('mrPlanDate')}
|
||||
readOnly
|
||||
error={errors.mrPlanDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
label="Appointment Date"
|
||||
type="date"
|
||||
value={formData.callDate}
|
||||
onChange={updateField('callDate')}
|
||||
required
|
||||
error={errors.callDate}
|
||||
min={dateConstraints.min}
|
||||
max={dateConstraints.max}
|
||||
/>
|
||||
|
||||
{/* Helper message directly below Appointment Date */}
|
||||
{currentCalendarDate && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
📅 Available dates:{' '}
|
||||
<span className="font-semibold">
|
||||
{new Date(currentCalendarDate.getFullYear(), currentCalendarDate.getMonth(), 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Material | Sampling Item Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
||||
Sampling & Promotional Materials
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Samples Table */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700">Samples</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchInventory();
|
||||
setIsSamplesModalOpen(true);
|
||||
}}
|
||||
disabled={!isDateSelected}
|
||||
>
|
||||
+ Add Samples
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isDateSelected && (
|
||||
<p className="text-sm text-amber-600 bg-amber-50 p-2 rounded mb-2">
|
||||
Please select an appointment date to add samples
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedSamples.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No samples selected</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500">Description</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500">Qty</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500">QtyBalance</th>
|
||||
<th className="px-2 py-2 text-center text-xs font-medium text-gray-500">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{selectedSamples.map((sample) => {
|
||||
// Calculate the max available (use maxQty if in edit mode, otherwise use qtyBalance)
|
||||
const maxAvailable = sample.maxQty || sample.qtyBalance;
|
||||
|
||||
// Calculate remaining balance: maxQty - selectedQty
|
||||
// This shows how much is left after the current selection
|
||||
const remainingBalance = maxAvailable - sample.selectedQty;
|
||||
|
||||
return (
|
||||
<tr key={sample.inventoryId}>
|
||||
<td className="px-2 py-2 text-gray-900">{sample.description}</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxAvailable}
|
||||
value={sample.selectedQty}
|
||||
onChange={(e) => {
|
||||
const newQty = Number(e.target.value);
|
||||
if (newQty > maxAvailable) {
|
||||
addToast({
|
||||
type: 'warning',
|
||||
title: 'Invalid Quantity',
|
||||
message: `Maximum available quantity is ${maxAvailable}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleSampleQtyChange(sample.inventoryId, newQty);
|
||||
}}
|
||||
className="w-16 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
readOnly
|
||||
value={remainingBalance} // Show remaining balance
|
||||
className="w-16 px-2 py-1 border rounded text-sm bg-gray-50"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveSample(sample.inventoryId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Promotional Materials Table */}
|
||||
<div className={`border rounded-lg p-4 ${errors.selectedPromos ? 'border-red-400 bg-red-50' : ''}`}>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700">Promotional Materials</h4>
|
||||
{errors.selectedPromos && (
|
||||
<p className="text-sm text-red-600 mt-2">
|
||||
{errors.selectedPromos}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchProducts();
|
||||
setIsPromosModalOpen(true);
|
||||
}}
|
||||
>
|
||||
+ Add Materials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPromos.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No materials selected</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500">Product Name</th>
|
||||
<th className="px-2 py-2 text-center text-xs font-medium text-gray-500">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{selectedPromos.map((promo) => (
|
||||
<tr key={promo.productId}>
|
||||
<td className="px-2 py-2 text-gray-900">{promo.productName}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemovePromo(promo.productId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Saving...' : appointment ? 'Update' : 'Create'} Appointment
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Samples Selection Modal */}
|
||||
<Modal
|
||||
isOpen={isSamplesModalOpen}
|
||||
onClose={() => {
|
||||
setIsSamplesModalOpen(false);
|
||||
setTempSelectedSampleIds(new Set());
|
||||
setSamplesSearchTerm('');
|
||||
}}
|
||||
title="Select Samples"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by description..."
|
||||
value={samplesSearchTerm}
|
||||
onChange={setSamplesSearchTerm}
|
||||
/>
|
||||
{samplesSearchTerm && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Found {monthFilteredInventory.length} items for {mode === 'edit' ? 'previous date' : 'appointment date'} month
|
||||
</p>
|
||||
)}
|
||||
{!samplesSearchTerm && monthFilteredInventory.length > 0 && (
|
||||
<p className="text-sm text-blue-600 mt-2">
|
||||
Showing {monthFilteredInventory.length} items available for {mode === 'edit' ? 'previous date' : 'appointment date'} month
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left w-12">
|
||||
<span className="sr-only">Select</span>
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Qty Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{monthFilteredInventory.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-gray-500">
|
||||
{samplesSearchTerm
|
||||
? `No items found matching "${samplesSearchTerm}" for the selected month`
|
||||
: 'No inventory items available for the selected appointment month'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
monthFilteredInventory.map((item) => {
|
||||
const qtyBalance = item.qtyIn - item.qtyOut;
|
||||
const isChecked = tempSelectedSampleIds.has(item.inventoryId);
|
||||
|
||||
return (
|
||||
<tr key={item.inventoryId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onChange={(checked) => handleSampleCheckbox(item.inventoryId, checked)}
|
||||
size="md"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{item.description}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{qtyBalance}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsSamplesModalOpen(false);
|
||||
setTempSelectedSampleIds(new Set());
|
||||
setSamplesSearchTerm('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleAddSamples}
|
||||
disabled={tempSelectedSampleIds.size === 0}
|
||||
>
|
||||
Add {tempSelectedSampleIds.size > 0 ? `(${tempSelectedSampleIds.size})` : ''} Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Promotional Materials Selection Modal */}
|
||||
<Modal
|
||||
isOpen={isPromosModalOpen}
|
||||
onClose={() => {
|
||||
setIsPromosModalOpen(false);
|
||||
setTempSelectedPromoIds(new Set());
|
||||
setPromosSearchTerm('');
|
||||
}}
|
||||
title="Select Promotional Materials"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by product name..."
|
||||
value={promosSearchTerm}
|
||||
onChange={setPromosSearchTerm}
|
||||
/>
|
||||
{promosSearchTerm && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Found {filteredProducts.length} of {products.length} items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left w-12">
|
||||
<span className="sr-only">Select</span>
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Product Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="px-4 py-8 text-center text-gray-500">
|
||||
{promosSearchTerm
|
||||
? `No items found matching "${promosSearchTerm}"`
|
||||
: 'No products available'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredProducts.map((item) => {
|
||||
const isChecked = tempSelectedPromoIds.has(item.productId);
|
||||
|
||||
return (
|
||||
<tr key={item.productId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onChange={(checked) => handlePromoCheckbox(item.productId, checked)}
|
||||
size="md"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{item.productName}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsPromosModalOpen(false);
|
||||
setTempSelectedPromoIds(new Set());
|
||||
setPromosSearchTerm('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleAddPromos}
|
||||
disabled={tempSelectedPromoIds.size === 0}
|
||||
>
|
||||
Add {tempSelectedPromoIds.size > 0 ? `(${tempSelectedPromoIds.size})` : ''} Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
319
src/components/appointment/MonthPlanCopy.tsx
Normal file
319
src/components/appointment/MonthPlanCopy.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import { Calendar, Copy, X, AlertTriangle } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface MonthPlanCopyProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentDate: Date;
|
||||
onConfirm: (sourceDate: string, targetDate: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const MonthPlanCopy: React.FC<MonthPlanCopyProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentDate,
|
||||
onConfirm,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const [targetMonth, setTargetMonth] = useState(
|
||||
String(currentDate.getMonth() + 2 > 12 ? 1 : currentDate.getMonth() + 2).padStart(2, '0')
|
||||
);
|
||||
const [targetYear, setTargetYear] = useState(
|
||||
String(currentDate.getMonth() + 2 > 12 ? currentDate.getFullYear() + 1 : currentDate.getFullYear())
|
||||
);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>('');
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const months = monthNames.map((name, index) => ({
|
||||
value: String(index + 1).padStart(2, '0'),
|
||||
label: name
|
||||
}));
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentDate.getFullYear() + i);
|
||||
|
||||
const formatSourceDate = () => {
|
||||
return `${monthNames[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
|
||||
};
|
||||
|
||||
const formatTargetDate = () => {
|
||||
const monthIndex = parseInt(targetMonth) - 1;
|
||||
return `${monthNames[monthIndex]} 1, ${targetYear}`;
|
||||
};
|
||||
|
||||
const formatSourceDateForAPI = () => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(currentDate.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatTargetDateForAPI = () => {
|
||||
return `${targetYear}-${targetMonth}-01`;
|
||||
};
|
||||
|
||||
// Validate that target date is in the future
|
||||
const validateTargetDate = (): boolean => {
|
||||
const sourceYear = currentDate.getFullYear();
|
||||
const sourceMonth = currentDate.getMonth() + 1; // JavaScript months are 0-indexed
|
||||
|
||||
const targetYearNum = parseInt(targetYear);
|
||||
const targetMonthNum = parseInt(targetMonth);
|
||||
|
||||
// Check if target is the same month and year
|
||||
if (targetYearNum === sourceYear && targetMonthNum === sourceMonth) {
|
||||
setValidationError('Cannot copy to the same month and year. Please select a future month.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if target is in the past
|
||||
if (targetYearNum < sourceYear ||
|
||||
(targetYearNum === sourceYear && targetMonthNum < sourceMonth)) {
|
||||
setValidationError('Cannot copy to a previous month. Please select a future month to avoid overwriting existing data.');
|
||||
return false;
|
||||
}
|
||||
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate whenever month or year changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
validateTargetDate();
|
||||
}
|
||||
}, [targetMonth, targetYear, isOpen]);
|
||||
|
||||
const handleProceed = () => {
|
||||
if (validateTargetDate()) {
|
||||
setShowConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmCopy = () => {
|
||||
onConfirm(formatSourceDateForAPI(), formatTargetDateForAPI());
|
||||
setShowConfirmation(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setValidationError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Selection Modal */}
|
||||
{!showConfirmation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-teal-100 p-2 rounded-lg">
|
||||
<Copy className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Copy Month Plan</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Source Month (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Copy From (Source)
|
||||
</label>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 flex items-center space-x-3">
|
||||
<Calendar className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-gray-900 font-medium">{formatSourceDate()}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Current calendar month</p>
|
||||
</div>
|
||||
|
||||
{/* Target Month Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Copy To (Target)
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Month Selector */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Month</label>
|
||||
<select
|
||||
value={targetMonth}
|
||||
onChange={(e) => setTargetMonth(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{months.map(month => (
|
||||
<option key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Year Selector */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Year</label>
|
||||
<select
|
||||
value={targetYear}
|
||||
onChange={(e) => setTargetYear(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{years.map(year => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Date Preview */}
|
||||
<div className={`mt-3 border rounded-lg p-3 ${
|
||||
validationError ? 'bg-red-50 border-red-200' : 'bg-teal-50 border-teal-200'
|
||||
}`}>
|
||||
<p className={`text-xs font-medium mb-1 ${
|
||||
validationError ? 'text-red-700' : 'text-teal-700'
|
||||
}`}>
|
||||
Target Date:
|
||||
</p>
|
||||
<p className={`text-sm font-semibold ${
|
||||
validationError ? 'text-red-900' : 'text-teal-900'
|
||||
}`}>
|
||||
{formatTargetDate()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Error */}
|
||||
{validationError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-800 mb-1">Invalid Date Selection</p>
|
||||
<p className="text-sm text-red-700">{validationError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
{!validationError && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-semibold">Note:</span> All appointments from {formatSourceDate()} will be copied to {formatTargetDate()}. The target date will always start from the 1st of the selected month.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProceed}
|
||||
disabled={isLoading || !!validationError}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
<span>Next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full">
|
||||
{/* Confirmation Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-orange-100 p-2 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Confirm Copy Operation</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Body */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-700">
|
||||
You are about to copy all appointment plans from:
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 font-medium mb-1">Source:</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{formatSourceDate()}</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-300 pt-3">
|
||||
<p className="text-xs text-gray-600 font-medium mb-1">Destination:</p>
|
||||
<p className="text-sm font-semibold text-teal-700">{formatTargetDate()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-sm text-orange-800">
|
||||
<span className="font-semibold">Warning:</span> This action will copy all appointments, doctors, and plan details to the selected target month. Make sure this is the correct destination before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmCopy}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Copying...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>Confirm & Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
3
src/components/appointment/index.ts
Normal file
3
src/components/appointment/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './AppointmentCard';
|
||||
export * from './AppointmentForm';
|
||||
export * from './MonthPlanCopy';
|
||||
18
src/components/common/ComingSoon.tsx
Normal file
18
src/components/common/ComingSoon.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ComingSoonProps {
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export const ComingSoon: React.FC<ComingSoonProps> = ({ title, icon: Icon }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Icon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600">This feature is coming soon!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
src/components/common/LoadingSpinner.tsx
Normal file
18
src/components/common/LoadingSpinner.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message = 'Loading...'
|
||||
}) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/components/common/index.ts
Normal file
2
src/components/common/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ComingSoon';
|
||||
export * from './LoadingSpinner';
|
||||
146
src/components/dashboard/DashboardOverview.tsx
Normal file
146
src/components/dashboard/DashboardOverview.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DashboardAPI } from '../../services/dashboard/dashboardApi';
|
||||
import { Doctor } from '@/types/dashboard/dashboard';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { LoadingSpinner } from '../ui';
|
||||
|
||||
interface DashboardOverviewProps {
|
||||
user: any;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const DashboardOverview: React.FC<DashboardOverviewProps> = ({ user }) => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [appointments, setAppointments] = useState<number>(0);
|
||||
const [reschedules, setReschedules] = useState<number>(0);
|
||||
const [missed, setMissed] = useState<number>(0);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
const loadDoctors = async () => {
|
||||
const data = await DashboardAPI.getAllDoctor();
|
||||
setDoctors(data || []);
|
||||
};
|
||||
|
||||
const loadAppointments = async () => {
|
||||
const data = await DashboardAPI.getAllAppointment();
|
||||
setAppointments(data?.length || 0);
|
||||
};
|
||||
|
||||
const loadReschedules = async () => {
|
||||
const data = await DashboardAPI.getAllReschedule();
|
||||
setReschedules(data?.length || 0);
|
||||
};
|
||||
const loadMissed = async () => {
|
||||
const data = await DashboardAPI.getAllMissed();
|
||||
setMissed(data?.length || 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
loadDoctors(),
|
||||
loadAppointments(),
|
||||
loadReschedules(),
|
||||
loadMissed()
|
||||
]);
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to load dashboard data'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ title: 'Total Doctors', value: doctors.length, change: '+12%', color: 'teal' },
|
||||
{ title: 'Appointments Today', value: appointments, change: '+15%', color: 'green' },
|
||||
{ title: 'Reschedule', value: reschedules, change: '+8%', color: 'blue' },
|
||||
{ title: 'Missed call', value: missed, change: '+5%', color: 'purple' }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner message="Loading please wait..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="bg-gradient-to-r from-teal-500 to-teal-600 rounded-xl p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
Welcome back, {user?.userName || 'User'}! 👋
|
||||
</h1>
|
||||
<p className="text-teal-100">
|
||||
Role: {user?.userRole || 'Medical Representative'} | Ready to make an impact today?
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl">🏥</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index}
|
||||
className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-teal-600 text-xl">📊</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-teal-600 bg-teal-50 px-2 py-1 rounded-full">
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">{stat.title}</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">
|
||||
Quick Actions for Medical Representative
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button className="flex flex-col items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-teal-300 hover:bg-teal-50 transition-colors">
|
||||
<span className="text-2xl mb-2">🏥</span>
|
||||
<span className="text-sm font-medium">Visit Doctor</span>
|
||||
</button>
|
||||
|
||||
<button className="flex flex-col items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-teal-300 hover:bg-teal-50 transition-colors">
|
||||
<span className="text-2xl mb-2">📋</span>
|
||||
<span className="text-sm font-medium">Product Demo</span>
|
||||
</button>
|
||||
|
||||
<button className="flex flex-col items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-teal-300 hover:bg-teal-50 transition-colors">
|
||||
<span className="text-2xl mb-2">✍️</span>
|
||||
<span className="text-sm font-medium">Collect Signature</span>
|
||||
</button>
|
||||
|
||||
<button className="flex flex-col items-center p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-teal-300 hover:bg-teal-50 transition-colors">
|
||||
<span className="text-2xl mb-2">📊</span>
|
||||
<span className="text-sm font-medium">View Reports</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/components/dashboard/index.ts
Normal file
2
src/components/dashboard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './DashboardOverview';
|
||||
export * from '../../pages/SettingsPage';
|
||||
95
src/components/doctor/DoctorCard.tsx
Normal file
95
src/components/doctor/DoctorCard.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { User, Mail, Phone, Stethoscope, Eye, Edit, Trash2 } from 'lucide-react';
|
||||
import { Doctor } from '../../types/doctor/doctor';
|
||||
import { Button } from '../ui';
|
||||
import { getStatusLabel, getStatusColor } from '../../utils/doctorStatus';
|
||||
import { ApiHelpers } from '../../utils/apiHelpers';
|
||||
|
||||
interface DoctorCardProps {
|
||||
doctor: Doctor;
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
showDeleteButton?: boolean;
|
||||
showEditButton?: boolean;
|
||||
}
|
||||
|
||||
export const DoctorCard: React.FC<DoctorCardProps> = ({
|
||||
doctor,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showDeleteButton = true,
|
||||
showEditButton= true,
|
||||
|
||||
}) => {
|
||||
const hasAnyAction = (showEditButton || showDeleteButton);
|
||||
|
||||
const userRole = ApiHelpers.getUserRole() || 'MEDREP';
|
||||
|
||||
if(userRole === 'DSM' || userRole === 'NSM')
|
||||
showDeleteButton= false;
|
||||
else
|
||||
showDeleteButton= true;
|
||||
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all duration-200 flex flex-col h-full min-h-[280px]">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div className="w-12 h-12 bg-teal-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-6 h-6 text-teal-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-gray-900 line-clamp-2">
|
||||
Dr. {doctor.firstName} {doctor.lastName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 truncate">{doctor.specialization?.specializationId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full border whitespace-nowrap ml-2 flex-shrink-0 ${getStatusColor(doctor.status)}`}>
|
||||
{getStatusLabel(doctor.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4 flex-grow">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{doctor.emailAddress}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{doctor.phoneNo}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Stethoscope className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{doctor.licenseNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditionally render Actions column */}
|
||||
{hasAnyAction && (
|
||||
<div className="flex space-x-2 mt-auto">
|
||||
<Button size="sm" variant="outline" onClick={onView}>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
|
||||
{showEditButton && (
|
||||
<Button size="sm" variant="secondary" onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showDeleteButton && (
|
||||
<Button size="sm" variant="danger" onClick={onDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
src/components/doctor/DoctorDetailView.tsx
Normal file
74
src/components/doctor/DoctorDetailView.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { User, Mail, Phone, MapPin, Stethoscope, Calendar } from 'lucide-react';
|
||||
import { Doctor } from '../../types/doctor/doctor';
|
||||
import { getStatusLabel, getStatusColor } from '../../utils/doctorStatus';
|
||||
|
||||
interface DoctorDetailViewProps {
|
||||
doctor: Doctor;
|
||||
}
|
||||
|
||||
export const DoctorDetailView: React.FC<DoctorDetailViewProps> = ({ doctor }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="w-16 h-16 bg-teal-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-8 h-8 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 line-clamp-2">
|
||||
Dr. {doctor.firstName} {doctor.lastName}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">{doctor.specialization?.specializationId}</p>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full border ${getStatusColor(doctor.status)}`}>
|
||||
{getStatusLabel(doctor.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Contact Information</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Mail className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<span className="text-gray-700">{doctor.emailAddress}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Phone className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<span className="text-gray-700">{doctor.phoneNo}</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<MapPin className="w-5 h-5 text-gray-400 mr-3 mt-0.5" />
|
||||
<span className="text-gray-700">{doctor.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Professional Details</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Stethoscope className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<span className="text-gray-700">{doctor.licenseNo || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Date of Birth</p>
|
||||
<p className="text-gray-700">{formatDate(doctor.birthDate || '')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
348
src/components/doctor/DoctorForm.tsx
Normal file
348
src/components/doctor/DoctorForm.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Doctor, DoctorFormData } from '../../types/doctor/doctor';
|
||||
import { Input, Button, Autocomplete } from '../ui';
|
||||
import { useInstitutionApi } from "../../hooks/generic/useInstitutionApi";
|
||||
import { useDistrictApi } from "../../hooks/generic/useDistrict";
|
||||
import { useSpecializationApi } from '../../hooks/doctor/useSpecializationApi';
|
||||
import { isValidEmail } from '../../utils/constants';
|
||||
|
||||
interface DoctorFormProps {
|
||||
doctor?: Doctor;
|
||||
onSubmit: (doctor: DoctorFormData) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DoctorForm: React.FC<DoctorFormProps> = ({
|
||||
doctor,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<DoctorFormData>({
|
||||
firstName: doctor?.firstName || '',
|
||||
middleInitial: doctor?.middleInitial || '',
|
||||
lastName: doctor?.lastName || '',
|
||||
emailAddress: doctor?.emailAddress ?? '',
|
||||
phoneNo: doctor?.phoneNo || '',
|
||||
specializationId : doctor?.specializationId || 0,
|
||||
licenseNo: doctor?.licenseNo ?? '',
|
||||
address: doctor?.address || '',
|
||||
institutionId: doctor?.institutionId || 0,
|
||||
institutionName: doctor?.institutionName || '',
|
||||
districtId: doctor?.districtId ?? null,
|
||||
districtName: doctor?.districtName || '',
|
||||
birthDate: doctor?.birthDate ??'',
|
||||
maxVisit: doctor?.maxVisit || 0,
|
||||
status: (doctor?.status as 1 | 0| 2) || 1,
|
||||
medRepName: doctor?.medRepName ?? '',
|
||||
isTerritorial: doctor?.isTerritorial ?? false
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [institutionsLoading, setInstitutionsLoading] = useState(false);
|
||||
const [districtsLoading, setDistrictsLoading] = useState(false);
|
||||
|
||||
const { institutions, fetchInstitutions } = useInstitutionApi();
|
||||
const { districts, fetchDistricts } = useDistrictApi();
|
||||
const { specializations, fetchSpecializations } = useSpecializationApi();
|
||||
// Load data when component mounts or when editing
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
|
||||
if (doctor) {
|
||||
if (doctor.institutionId) {
|
||||
setInstitutionsLoading(true);
|
||||
await fetchInstitutions(),fetchSpecializations();
|
||||
setInstitutionsLoading(false);
|
||||
}
|
||||
|
||||
// Load districts if we have a districtId
|
||||
if (doctor.districtId) {
|
||||
setDistrictsLoading(true);
|
||||
await fetchDistricts();
|
||||
setDistrictsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
}, [doctor?.institutionId, doctor?.districtId]);
|
||||
|
||||
|
||||
const specializationOptions = specializations
|
||||
.filter(s => s.isActive !== false)
|
||||
.map(s=> ({
|
||||
value: s.specializationId,
|
||||
label: s.specializationName
|
||||
}));
|
||||
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.firstName.trim()) newErrors.firstName = 'First name is required';
|
||||
if (!formData.lastName.trim()) newErrors.lastName = 'Last name is required';
|
||||
|
||||
if (formData.emailAddress && !isValidEmail(formData.emailAddress)) {
|
||||
newErrors.emailAddress = 'Email address is invalid';
|
||||
}
|
||||
if (!formData.specializationId) newErrors.specialization = 'Specialization is required';
|
||||
if (!formData.districtId) newErrors.district = 'District is required';
|
||||
if (!formData.institutionId) newErrors.institution = 'Institution is required';
|
||||
// if (!formData.licenseNo.trim()) newErrors.licenseNumber = 'License number is required';
|
||||
|
||||
if (!formData.maxVisit) {
|
||||
newErrors.maxVisit = 'Max visit is required';
|
||||
} else if (formData.maxVisit < 1) {
|
||||
newErrors.maxVisit = 'Max visit must be at least 1';
|
||||
} else if (formData.maxVisit > 4) {
|
||||
newErrors.maxVisit = 'Max visit cannot exceed 4';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof typeof formData) => (value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
|
||||
if (field === 'maxVisit') {
|
||||
const numValue = typeof value === 'number' ? value : parseInt(value);
|
||||
if (value && (numValue < 1 || numValue > 4)) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
maxVisit: 'Max visit must be between 1 and 4'
|
||||
}));
|
||||
} else {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors.maxVisit;
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle institution focus/load - only load if not already loaded
|
||||
const handleInstitutionFocus = async () => {
|
||||
if (institutions.length === 0 && !institutionsLoading) {
|
||||
setInstitutionsLoading(true);
|
||||
await fetchInstitutions();
|
||||
setInstitutionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle district focus/load - only load if not already loaded
|
||||
const handleDistrictFocus = async () => {
|
||||
if (districts.length === 0 && !districtsLoading) {
|
||||
setDistrictsLoading(true);
|
||||
await fetchDistricts();
|
||||
setDistrictsLoading(false);
|
||||
}
|
||||
};
|
||||
const handleSpecification = async () => {
|
||||
if (districts.length === 0 && !districtsLoading) {
|
||||
setDistrictsLoading(true);
|
||||
await fetchSpecializations();
|
||||
setDistrictsLoading(false);
|
||||
}
|
||||
};
|
||||
// Prepare institution options for autocomplete
|
||||
const institutionOptions = institutions.map((i) => ({
|
||||
value: i.institutionId,
|
||||
label: `${i.institutionCategory?.institutionCategoryCode ?? ''} - ${i.institutionName}`,
|
||||
searchText: `${i.institutionCategory?.institutionCategoryCode ?? ''} ${i.institutionName} ${i.territoryName ?? ''}`.toLowerCase()
|
||||
}));
|
||||
|
||||
// Prepare district options for autocomplete
|
||||
const districtOptions = districts.map((d) => ({
|
||||
value: d.districtId,
|
||||
label: d.districtName,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Personal Information Section */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
||||
Personal Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input
|
||||
label="First Name"
|
||||
value={formData.firstName}
|
||||
onChange={updateField('firstName')}
|
||||
placeholder="First name"
|
||||
required
|
||||
error={errors.firstName}
|
||||
/>
|
||||
<Input
|
||||
label="Middle Initial"
|
||||
value={formData.middleInitial}
|
||||
onChange={updateField('middleInitial')}
|
||||
placeholder="M.I."
|
||||
error={errors.middleInitial}
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={formData.lastName}
|
||||
onChange={updateField('lastName')}
|
||||
placeholder="Last name"
|
||||
required
|
||||
error={errors.lastName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
value={formData.birthDate ?? ''}
|
||||
onChange={updateField('birthDate')}
|
||||
error={errors.birthDate}
|
||||
/>
|
||||
<Input
|
||||
label="License Number"
|
||||
value={formData.licenseNo ?? ''}
|
||||
onChange={updateField('licenseNo')}
|
||||
placeholder="License #"
|
||||
error={errors.licenseNumber}
|
||||
/>
|
||||
<Input
|
||||
label="Status"
|
||||
value={formData.status}
|
||||
onChange={updateField('status')}
|
||||
readOnly
|
||||
type="approvalStatus"
|
||||
error={errors.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information Section */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
||||
Contact Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={formData.emailAddress ?? ''}
|
||||
onChange={updateField('emailAddress')}
|
||||
placeholder="email@example.com"
|
||||
error={errors.emailAddress}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
value={formData.phoneNo}
|
||||
onChange={updateField('phoneNo')}
|
||||
placeholder="Phone number"
|
||||
error={errors.phoneNo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Address"
|
||||
value={formData.address}
|
||||
onChange={updateField('address')}
|
||||
placeholder="Full address"
|
||||
required
|
||||
error={errors.address}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Professional Information Section */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
||||
Professional Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Autocomplete
|
||||
label="Specialization"
|
||||
value={formData.specializationId}
|
||||
onChange={updateField('specializationId')}
|
||||
onFocus={handleSpecification}
|
||||
options={specializationOptions}
|
||||
placeholder="Select specialization"
|
||||
searchPlaceholder="Search by name..."
|
||||
required
|
||||
error={errors.specialization}
|
||||
/>
|
||||
|
||||
{/* Autocomplete for Institution */}
|
||||
<Autocomplete
|
||||
label="Code - Institution"
|
||||
value={formData.institutionId}
|
||||
onChange={updateField('institutionId')}
|
||||
options={institutionOptions}
|
||||
onFocus={handleInstitutionFocus}
|
||||
loading={institutionsLoading}
|
||||
placeholder="Select institution"
|
||||
searchPlaceholder="Search by code or name..."
|
||||
required
|
||||
error={errors.institution}
|
||||
minSearchLength={0}
|
||||
maxHeight="250px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Autocomplete for District */}
|
||||
<Autocomplete
|
||||
label="District"
|
||||
value={formData.districtId}
|
||||
onChange={updateField('districtId')}
|
||||
options={districtOptions}
|
||||
onFocus={handleDistrictFocus}
|
||||
loading={districtsLoading}
|
||||
placeholder="Select district"
|
||||
searchPlaceholder="Search district..."
|
||||
required
|
||||
error={errors.district}
|
||||
minSearchLength={0}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Frequency"
|
||||
type="number"
|
||||
value={formData.maxVisit}
|
||||
onChange={updateField('maxVisit')}
|
||||
placeholder="1-4"
|
||||
required
|
||||
error={errors.maxVisit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Actions */}
|
||||
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
variant={doctor ? 'warning' : 'primary'}
|
||||
>
|
||||
{isLoading ? 'Saving...' : doctor ? 'Update Doctor' : 'Add Doctor'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
3
src/components/doctor/index.ts
Normal file
3
src/components/doctor/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './DoctorForm';
|
||||
export * from './DoctorCard';
|
||||
export * from './DoctorDetailView';
|
||||
357
src/components/features/auth/LoginPage.tsx
Normal file
357
src/components/features/auth/LoginPage.tsx
Normal file
@ -0,0 +1,357 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Stethoscope, UserCheck, Lock, User, Shield } from 'lucide-react';
|
||||
import { useAuth } from '../../../hooks/useAuth';
|
||||
import { useApi } from '../../../hooks/useApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface FormData {
|
||||
userName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
userName?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
expiration: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
fullName: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
const LoginPage = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
userName: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const { login } = useAuth();
|
||||
const { apiCall } = useApi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof FormErrors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.userName) {
|
||||
newErrors.userName = 'Username is required';
|
||||
} else if (formData.userName.length < 3) {
|
||||
newErrors.userName = 'Username must be at least 3 characters';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Password must be at least 6 characters';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Helper function to decode JWT and extract claims
|
||||
const parseJwt = (token: string) => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
console.error('Error parsing JWT:', e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
const loginApiCall = async () => {
|
||||
const response = await fetch(`${baseUrl}/AnonAccount/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userName: formData.userName,
|
||||
password: formData.password
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || errorData.error || 'Login failed');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
// Use your reusable apiCall hook
|
||||
const data: LoginResponse = await apiCall(
|
||||
loginApiCall,
|
||||
'Login successful! Welcome back.',
|
||||
'Login failed. Please check your credentials.'
|
||||
);
|
||||
|
||||
if (data && data.token) {
|
||||
// Decode JWT to extract claims and roles
|
||||
const decodedToken = parseJwt(data.token);
|
||||
|
||||
// Extract user role from claims
|
||||
const userRole = decodedToken['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'] || 'User';
|
||||
|
||||
// Build user claims array from decoded token
|
||||
const userClaims = Object.entries(decodedToken).map(([type, value]) => ({
|
||||
type,
|
||||
value: String(value)
|
||||
}));
|
||||
|
||||
// Store authentication data with all user information
|
||||
const authData = {
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
fullName: data.fullName,
|
||||
userRole: userRole,
|
||||
company: data.company,
|
||||
userClaims: userClaims,
|
||||
token: data.token,
|
||||
expiration: data.expiration,
|
||||
};
|
||||
|
||||
// auth context
|
||||
await login(authData);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-teal-50 via-cyan-50 to-blue-50 flex items-center justify-center p-4">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-teal-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-cyan-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse delay-1000"></div>
|
||||
<div className="absolute top-40 left-40 w-60 h-60 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-50 animate-pulse delay-2000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/80 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20 p-8 space-y-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto w-16 h-14 bg-gradient-to-r from-teal-500 to-cyan-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Stethoscope className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-1">
|
||||
LLI - Sales Force
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
MD Call Management System
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Banner */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 bg-teal-100 rounded-lg mx-auto mb-2 flex items-center justify-center">
|
||||
<UserCheck className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Doctor Visits</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 bg-cyan-100 rounded-lg mx-auto mb-2 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-cyan-600" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">E-Signatures</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg mx-auto mb-2 flex items-center justify-center">
|
||||
<Stethoscope className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Product Sales</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="space-y-6" onKeyPress={handleKeyPress}>
|
||||
|
||||
{/* Username Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-teal-600" />
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="userName"
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
className={`w-full px-4 py-3 rounded-xl border-2 transition-all duration-300 bg-white/50 backdrop-blur-sm
|
||||
${errors.userName
|
||||
? 'border-red-300 focus:border-red-500'
|
||||
: 'border-gray-200 focus:border-teal-500'
|
||||
}
|
||||
focus:outline-none focus:ring-4 focus:ring-teal-100`}
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
{errors.userName && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.userName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-teal-600" />
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
className={`w-full px-4 py-3 pr-12 rounded-xl border-2 transition-all duration-300 bg-white/50 backdrop-blur-sm
|
||||
${errors.password
|
||||
? 'border-red-300 focus:border-red-500'
|
||||
: 'border-gray-200 focus:border-teal-500'
|
||||
}
|
||||
focus:outline-none focus:ring-4 focus:ring-teal-100`}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-teal-600 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-teal-600 bg-gray-100 border-gray-300 rounded focus:ring-teal-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-gray-600">Remember me</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-teal-600 hover:text-teal-700 font-medium transition-colors"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-teal-500 to-cyan-500 text-white py-3 px-6 rounded-xl font-semibold
|
||||
hover:from-teal-600 hover:to-cyan-600 transform hover:scale-[1.02] transition-all duration-300
|
||||
focus:outline-none focus:ring-4 focus:ring-teal-200 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Signing In...
|
||||
</div>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center space-y-4 pt-4 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500">
|
||||
Secure medical representative portal for healthcare professionals
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-gray-400">
|
||||
<span>© 2025 MedRep Portal</span>
|
||||
<span>•</span>
|
||||
<span>Healthcare Solutions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mt-6 text-center">
|
||||
<div className="inline-flex items-center gap-6 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="w-3 h-3 text-teal-600" />
|
||||
<span>256-bit SSL</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<UserCheck className="w-3 h-3 text-teal-600" />
|
||||
<span>HIPAA Compliant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Lock className="w-3 h-3 text-teal-600" />
|
||||
<span>Secure Login</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
1123
src/components/generic/GenericMaintenanceTab.tsx
Normal file
1123
src/components/generic/GenericMaintenanceTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
src/components/layout/Header.tsx
Normal file
47
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Search, Bell, Menu, User } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onSidebarToggle: () => void;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, onSidebarToggle }) => {
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onSidebarToggle}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800">{title}</h2>
|
||||
<p className="text-sm text-gray-600">Welcome back! Here's what's happening today.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative hidden md:block">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 relative">
|
||||
<Bell className="w-6 h-6" />
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
<div className="w-8 h-8 bg-teal-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
71
src/components/layout/Layout.tsx
Normal file
71
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
// src/components/Layout.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
user?: any;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
user,
|
||||
onLogout
|
||||
}) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { navigationItems, isLoading, error } = useNavigation();
|
||||
|
||||
const getPageTitle = () => {
|
||||
// Find the active navigation item
|
||||
const activeItem = navigationItems.find(item => item.id === activeTab);
|
||||
return activeItem?.label || 'Dashboard';
|
||||
};
|
||||
|
||||
// Show error if navigation fails to load
|
||||
if (error && !isLoading) {
|
||||
console.error('Navigation error:', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => {
|
||||
onTabChange(tab);
|
||||
setSidebarOpen(false); // Close sidebar on mobile after selection
|
||||
}}
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
navigationItems={navigationItems}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<Header
|
||||
title={getPageTitle()}
|
||||
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
227
src/components/layout/Sidebar.tsx
Normal file
227
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, ChevronDown, ChevronRight, Menu, User, LogOut, Stethoscope } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { NavigationItem } from '../../types/navigation';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
user?: any;
|
||||
onLogout?: () => void;
|
||||
navigationItems: NavigationItem[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
user,
|
||||
onLogout,
|
||||
navigationItems,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||
|
||||
const getIconComponent = (iconName: string) => {
|
||||
const IconComponent = (LucideIcons as any)[iconName];
|
||||
return IconComponent || LucideIcons.Home;
|
||||
};
|
||||
|
||||
const toggleMenu = (menuId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering parent click
|
||||
const newExpanded = new Set(expandedMenus);
|
||||
if (newExpanded.has(menuId)) {
|
||||
newExpanded.delete(menuId);
|
||||
} else {
|
||||
newExpanded.add(menuId);
|
||||
}
|
||||
setExpandedMenus(newExpanded);
|
||||
};
|
||||
|
||||
const handleMenuClick = (item: NavigationItem) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
// If item has a route (is clickable), navigate to it
|
||||
if (item.route) {
|
||||
onTabChange(item.id);
|
||||
// Close sidebar on mobile only when selecting a final destination
|
||||
if (window.innerWidth < 1024) {
|
||||
onToggle();
|
||||
}
|
||||
} else if (hasChildren) {
|
||||
// If no route but has children, just expand/collapse
|
||||
setExpandedMenus(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(item.id)) {
|
||||
newExpanded.delete(item.id);
|
||||
} else {
|
||||
newExpanded.add(item.id);
|
||||
}
|
||||
return newExpanded;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: NavigationItem, level: number = 0) => {
|
||||
const isActive = activeTab === item.id;
|
||||
const isExpanded = expandedMenus.has(item.id);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const Icon = getIconComponent(item.icon);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{/* Parent Menu Item */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`w-full flex items-center justify-between transition-all duration-200 ${
|
||||
level > 0 ? 'pl-8' : 'pl-4'
|
||||
} pr-4 py-3 ${
|
||||
isActive
|
||||
? 'bg-teal-700 border-r-4 border-teal-300'
|
||||
: 'hover:bg-teal-700/50'
|
||||
}`}
|
||||
>
|
||||
{/* Main clickable area - icon and label */}
|
||||
<button
|
||||
onClick={() => handleMenuClick(item)}
|
||||
className={`flex items-center flex-1 text-left ${!isOpen && 'justify-center'}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 text-white flex-shrink-0" />
|
||||
{isOpen && (
|
||||
<>
|
||||
<span className="ml-3 font-medium">{item.label}</span>
|
||||
{item.badge && !hasChildren && (
|
||||
<span className="ml-auto bg-teal-600 text-xs px-2 py-1 rounded-full">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Separate expand/collapse button */}
|
||||
{isOpen && hasChildren && (
|
||||
<button
|
||||
onClick={(e) => toggleMenu(item.id, e)}
|
||||
className="ml-2 p-1 hover:bg-teal-600 rounded transition-colors"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tooltip for collapsed sidebar */}
|
||||
{!isOpen && (
|
||||
<div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap z-50 pointer-events-none">
|
||||
{item.label}
|
||||
{item.badge && (
|
||||
<span className="ml-2 bg-teal-600 text-xs px-2 py-0.5 rounded-full">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-gray-900"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Child Menu Items */}
|
||||
{hasChildren && isExpanded && isOpen && (
|
||||
<div className="bg-teal-900/30">
|
||||
{item.children!.map((child) => renderMenuItem(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed left-0 top-0 h-full bg-gradient-to-b from-teal-800 to-teal-900 text-white transition-all duration-300 z-50 ${
|
||||
isOpen ? 'w-64' : 'w-16'
|
||||
} lg:relative lg:translate-x-0 ${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-teal-700">
|
||||
<div className={`flex items-center space-x-3 ${!isOpen && 'lg:justify-center'}`}>
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<Stethoscope className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">LLI-SFE</h1>
|
||||
<p className="text-xs text-teal-200">{user?.company || 'Role'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
{isOpen ? <ChevronLeft className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="mt-8" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-3">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-10 bg-teal-700 rounded"></div>
|
||||
<div className="h-10 bg-teal-700 rounded"></div>
|
||||
<div className="h-10 bg-teal-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : navigationItems.length === 0 ? (
|
||||
<div className="px-4 py-3 text-center text-teal-200 text-sm">
|
||||
No menu items available
|
||||
</div>
|
||||
) : (
|
||||
navigationItems.map((item) => renderMenuItem(item))
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="absolute bottom-0 w-full p-4 border-t border-teal-700">
|
||||
<div className={`flex items-center ${!isOpen && 'justify-center'}`}>
|
||||
<div className="w-10 h-10 bg-teal-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium">{user?.userName || 'User'}</p>
|
||||
<p className="text-xs text-teal-200">{user?.userRole || 'Role'}</p>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-2 hover:bg-teal-700 rounded-lg transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './Sidebar';
|
||||
export * from './Header';
|
||||
export * from './Layout';
|
||||
1625
src/components/reports/ReportsTab.tsx
Normal file
1625
src/components/reports/ReportsTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
280
src/components/ui/AutocompleteOption.tsx
Normal file
280
src/components/ui/AutocompleteOption.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
searchText?: string; // Optional: custom text to search against
|
||||
}
|
||||
|
||||
interface AutocompleteProps {
|
||||
label?: string;
|
||||
value: string | number | null;
|
||||
onChange: (value: string | number) => void;
|
||||
options: AutocompleteOption[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onFocus?: () => void;
|
||||
searchPlaceholder?: string;
|
||||
noResultsText?: string;
|
||||
emptyText?: string;
|
||||
maxHeight?: string;
|
||||
minSearchLength?: number;
|
||||
}
|
||||
|
||||
export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
required,
|
||||
error,
|
||||
disabled,
|
||||
loading = false,
|
||||
onFocus,
|
||||
searchPlaceholder = 'Search...',
|
||||
noResultsText = 'No results found',
|
||||
emptyText = 'Start typing to search',
|
||||
maxHeight = '300px',
|
||||
minSearchLength = 0,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get selected option label
|
||||
const selectedOption =
|
||||
value !== null ? options.find(opt => opt.value === value) : undefined;
|
||||
|
||||
const displayValue = selectedOption?.label ?? '';
|
||||
|
||||
// Filter options based on search term
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchTerm || searchTerm.length < minSearchLength) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
return options.filter(option => {
|
||||
const searchText = option.searchText || option.label;
|
||||
return searchText.toLowerCase().includes(lowerSearch);
|
||||
});
|
||||
}, [options, searchTerm, minSearchLength]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && listRef.current) {
|
||||
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const handleInputClick = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(true);
|
||||
if (onFocus) onFocus();
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setHighlightedIndex(-1);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleOptionClick = (optionValue: string | number) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
setHighlightedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0));
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
||||
handleOptionClick(filteredOptions[highlightedIndex].value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
setHighlightedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
setSearchTerm('');
|
||||
setHighlightedIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1" ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Display Input */}
|
||||
<div
|
||||
onClick={handleInputClick}
|
||||
className={`w-full px-2.5 py-1.5 text-sm border rounded-lg cursor-pointer transition-all ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300 hover:border-gray-400'
|
||||
} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} ${
|
||||
isOpen ? 'ring-1 ring-teal-500 border-transparent' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={displayValue ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{value && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full px-2.5 py-1.5 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-teal-500 focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options List */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500">
|
||||
Loading options...
|
||||
</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500">
|
||||
{searchTerm.length < minSearchLength ? emptyText : noResultsText}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
option.value === value
|
||||
? 'bg-teal-50 text-teal-700 font-medium'
|
||||
: highlightedIndex === index
|
||||
? 'bg-gray-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
{!loading && filteredOptions.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-xs text-gray-500 border-t border-gray-200 bg-gray-50">
|
||||
Showing {filteredOptions.length} of {options.length} options
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
src/components/ui/Button.tsx
Normal file
51
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline' | 'warning';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled,
|
||||
className = '',
|
||||
type = 'button'
|
||||
}) => {
|
||||
const baseStyles = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 inline-flex items-center justify-center';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-teal-600 hover:bg-teal-700 text-white focus:ring-teal-500 shadow-md hover:shadow-lg',
|
||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 shadow-md hover:shadow-lg',
|
||||
success: 'bg-emerald-600 hover:bg-emerald-700 text-white focus:ring-emerald-500 shadow-md hover:shadow-lg',
|
||||
outline: 'border-2 border-teal-600 text-teal-600 hover:bg-teal-50 focus:ring-teal-500',
|
||||
warning: 'bg-amber-500 hover:bg-amber-600 text-white focus:ring-amber-400 shadow-md hover:shadow-lg'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${disabledStyles} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
0
src/components/ui/Card.tsx
Normal file
0
src/components/ui/Card.tsx
Normal file
117
src/components/ui/CheckboxProps.tsx
Normal file
117
src/components/ui/CheckboxProps.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface CheckboxProps {
|
||||
id?: string;
|
||||
label?: string;
|
||||
checked?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'primary' | 'success' | 'danger';
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
id,
|
||||
label,
|
||||
checked = false,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
className = '',
|
||||
labelClassName = '',
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
indeterminate = false,
|
||||
}) => {
|
||||
const checkboxRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle indeterminate state
|
||||
React.useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'text-gray-600 focus:ring-gray-500',
|
||||
primary: 'text-blue-600 focus:ring-blue-500',
|
||||
success: 'text-teal-600 focus:ring-teal-500',
|
||||
danger: 'text-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const labelSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
const checkboxClasses = `
|
||||
${sizeClasses[size]}
|
||||
${variantClasses[variant]}
|
||||
rounded border-gray-300
|
||||
focus:ring-2 focus:ring-offset-0
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
transition-colors
|
||||
${className}
|
||||
`.trim();
|
||||
|
||||
const labelClasses = `
|
||||
${labelSizeClasses[size]}
|
||||
font-medium text-gray-700
|
||||
${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
|
||||
${labelClassName}
|
||||
`.trim();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!disabled) {
|
||||
onChange(e.target.checked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={checkboxClasses}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
/>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`ml-2 ${labelClasses}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
className="mt-1 text-xs text-red-600"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export for use in forms
|
||||
export default Checkbox;
|
||||
59
src/components/ui/ConfirmModal.tsx
Normal file
59
src/components/ui/ConfirmModal.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { ConfirmModalProps } from '../../types/modal';
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
type = 'danger',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
loading = false,
|
||||
}) => {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getButtonStyles = () => {
|
||||
switch (type) {
|
||||
case 'danger': return 'bg-red-600 hover:bg-red-700 text-white';
|
||||
case 'warning': return 'bg-amber-600 hover:bg-amber-700 text-white';
|
||||
case 'info': return 'bg-teal-600 hover:bg-teal-700 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<div className="space-y-4">
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-3 text-sm text-gray-600">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-600">{message}</p>
|
||||
<div className="flex space-x-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${getButtonStyles()}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
157
src/components/ui/Input.tsx
Normal file
157
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { getStatusColor, getStatusLabel } from '../../utils/doctorStatus';
|
||||
|
||||
interface InputProps {
|
||||
label?: string;
|
||||
type?: string;
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
min?: string;
|
||||
max?: string;
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
error,
|
||||
readOnly,
|
||||
disabled,
|
||||
min,
|
||||
max
|
||||
}) => {
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const getVisitStatusColor = (status: string) => {
|
||||
const normalized = (status || '').toLowerCase().trim();
|
||||
switch (normalized) {
|
||||
case 'partial': return 'bg-blue-100 text-blue-800';
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'none': return 'bg-gray-100 text-orange-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to extract first date from comma-separated dates
|
||||
const getFirstDate = (dateString: string): string => {
|
||||
if (!dateString) return '';
|
||||
const dates = dateString.split(',');
|
||||
const firstDate = dates[0].trim().split('T')[0].trim();
|
||||
return firstDate;
|
||||
};
|
||||
|
||||
// Get the display value for date inputs
|
||||
const getDateValue = (): string => {
|
||||
if (type === 'date' && typeof value === 'string') {
|
||||
if (value.includes(',') || value.includes('T')) {
|
||||
return getFirstDate(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Determine the actual input type (for password toggle)
|
||||
const inputType = type === 'password' && showPassword ? 'text' : type;
|
||||
|
||||
// Toggle password visibility
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
// Hidden input type
|
||||
if (type === 'hidden') {
|
||||
return (
|
||||
<input
|
||||
type="hidden"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{readOnly && type === 'status' ? (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${getVisitStatusColor(
|
||||
String(value)
|
||||
)}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
) : readOnly && type === 'approvalStatus' ? (
|
||||
<span
|
||||
className={`inline-block px-2.5 py-0.5 rounded-full border text-xs font-medium ${getStatusColor(
|
||||
Number(value)
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(Number(value))}
|
||||
</span>
|
||||
) : readOnly && type === 'date' && typeof value === 'string' && value.includes(',') ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.split(',').map((date, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block px-2 py-0.5 bg-teal-100 text-teal-800 rounded text-xs font-medium"
|
||||
>
|
||||
{date.trim().split('T')[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={inputType}
|
||||
value={getDateValue()}
|
||||
onChange={(e) => !readOnly && onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
className={`w-full px-2.5 py-1.5 text-sm border rounded-lg focus:ring-1 focus:ring-teal-500 focus:border-transparent transition-all ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
} ${readOnly || disabled ? 'bg-gray-100 cursor-not-allowed' : ''} ${
|
||||
type === 'password' ? 'pr-9' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Password Toggle Button */}
|
||||
{type === 'password' && !readOnly && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/ui/LoadingSpinnerProps.tsx
Normal file
45
src/components/ui/LoadingSpinnerProps.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
fullScreen?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'teal' | 'blue' | 'gray' | 'primary';
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message = 'Loading...',
|
||||
fullScreen = true,
|
||||
size = 'md',
|
||||
color = 'teal'
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-12 w-12',
|
||||
lg: 'h-16 w-16'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
teal: 'border-teal-600',
|
||||
blue: 'border-blue-600',
|
||||
gray: 'border-gray-600',
|
||||
primary: 'border-primary-600'
|
||||
};
|
||||
|
||||
const containerClasses = fullScreen
|
||||
? 'min-h-screen bg-gray-50 flex items-center justify-center'
|
||||
: 'flex items-center justify-center py-12';
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full border-b-2 mx-auto ${sizeClasses[size]} ${colorClasses[color]}`}
|
||||
></div>
|
||||
{message && (
|
||||
<p className="mt-4 text-gray-600">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
src/components/ui/Modal.tsx
Normal file
97
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
||||
static?: boolean; // Optional static backdrop
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
static: isStatic = false
|
||||
}) => {
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : 'unset';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// ✅ Added larger modal sizes here
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
'2xl': 'max-w-6xl',
|
||||
'3xl': 'max-w-7xl',
|
||||
full: 'max-w-[95vw]' // almost full width, still with padding
|
||||
};
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (isStatic) {
|
||||
// Trigger shake animation if modal is static
|
||||
setIsShaking(true);
|
||||
setTimeout(() => setIsShaking(false), 500);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-30 transition-opacity"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div
|
||||
className={`relative bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} transform transition-all max-h-[100vh] overflow-y-auto ${
|
||||
isShaking ? 'animate-shake' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 ml-2 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline animation */}
|
||||
<style>{`
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
src/components/ui/RadioOption.tsx
Normal file
66
src/components/ui/RadioOption.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
|
||||
interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
label?: string;
|
||||
value: boolean | string;
|
||||
onChange: (value: string) => void;
|
||||
options?: RadioOption[];
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RadioGroup: React.FC<RadioGroupProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options = [
|
||||
{ value: 'true', label: 'Yes' },
|
||||
{ value: 'false', label: 'No' }
|
||||
],
|
||||
required,
|
||||
error,
|
||||
disabled = false
|
||||
}) => {
|
||||
// Convert boolean to string for comparison
|
||||
const stringValue = String(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center space-x-2 cursor-pointer ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={option.value}
|
||||
checked={stringValue === option.value}
|
||||
onChange={(e) => !disabled && onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 text-teal-600 border-gray-300 focus:ring-2 focus:ring-teal-500 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
src/components/ui/Select.tsx
Normal file
68
src/components/ui/Select.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
label?: string;
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
onFocus?: React.FocusEventHandler<HTMLSelectElement>;
|
||||
onClick?: React.MouseEventHandler<HTMLSelectElement>;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
required,
|
||||
error,
|
||||
disabled,
|
||||
onFocus,
|
||||
onClick
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2.5 py-1.5 text-sm border rounded-lg focus:ring-1 focus:ring-teal-500 focus:border-transparent transition-all ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
} ${disabled ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled hidden={!!value}>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
src/components/ui/Textarea.tsx
Normal file
66
src/components/ui/Textarea.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { } from 'react';
|
||||
|
||||
interface TextareaProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const Textarea: React.FC<TextareaProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
error,
|
||||
disabled,
|
||||
rows = 3,
|
||||
className = '',
|
||||
maxLength
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
className={`
|
||||
w-full px-2.5 py-1.5 text-sm border rounded-lg
|
||||
focus:ring-1 focus:ring-teal-500 focus:border-transparent
|
||||
transition-all
|
||||
resize-y overflow-auto
|
||||
${error ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed resize-none' : ''}
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{maxLength && (
|
||||
<p className="text-xs text-gray-500 ml-auto">
|
||||
{value.length} / {maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/components/ui/Toast.tsx
Normal file
60
src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Check, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
||||
import { ToastType } from '../../types/toast';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastType;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({ toast }) => {
|
||||
const { removeToast } = useToast();
|
||||
|
||||
const getIcon = () => {
|
||||
switch (toast.type) {
|
||||
case 'success': return <Check className="w-5 h-5" />;
|
||||
case 'error': return <XCircle className="w-5 h-5" />;
|
||||
case 'warning': return <AlertCircle className="w-5 h-5" />;
|
||||
case 'info': return <Info className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
switch (toast.type) {
|
||||
case 'success': return 'bg-emerald-50 border-emerald-200 text-emerald-800';
|
||||
case 'error': return 'bg-red-50 border-red-200 text-red-800';
|
||||
case 'warning': return 'bg-amber-50 border-amber-200 text-amber-800';
|
||||
case 'info': return 'bg-teal-50 border-teal-200 text-teal-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${getStyles()} border rounded-lg p-4 shadow-lg transition-all duration-300 transform translate-x-0`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">{getIcon()}</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="font-medium">{toast.title}</h3>
|
||||
{toast.message && <p className="mt-1 text-sm opacity-90">{toast.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-4 flex-shrink-0 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast key={toast.id} toast={toast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/components/ui/index.ts
Normal file
11
src/components/ui/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export * from './Button';
|
||||
export * from './Input';
|
||||
export * from './Select';
|
||||
export * from './Modal';
|
||||
export * from './ConfirmModal';
|
||||
export * from './Toast';
|
||||
export * from './RadioOption';
|
||||
export * from './AutocompleteOption';
|
||||
export * from './LoadingSpinnerProps';
|
||||
export * from './Textarea';
|
||||
export * from './CheckboxProps';
|
||||
177
src/config/endpoints.ts
Normal file
177
src/config/endpoints.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { DefaultEndpoints, RoleEndpoints } from "./roleEndpoints";
|
||||
|
||||
export const ROLE_ENDPOINTS: Record<string, RoleEndpoints> = {
|
||||
MEDREP: {
|
||||
doctor: {
|
||||
getAll: '/DoctorMgmt/GetDoctor',
|
||||
getById: '/DoctorMgmt/GetDoctor',
|
||||
getSpecialization:'/Maintenance/Specialization',
|
||||
create: '/DoctorMgmt/PostPutDoctor',
|
||||
update: '/DoctorMgmt/PostPutDoctor',
|
||||
delete: '/DoctorMgmt',
|
||||
approve: '/DoctorMgmt/ApproveDoctor',
|
||||
getPending: '/DoctorMgmt/GetPendingApprovals',
|
||||
},
|
||||
},
|
||||
DSM: {
|
||||
doctor: {
|
||||
getAll: '/DoctorMgmt/GetPendingApprovals',
|
||||
getById: '/DoctorMgmt/GetDoctor',
|
||||
getSpecialization:'/Maintenance/Specialization',
|
||||
create: '/DoctorMgmt/ApproveDoctor',
|
||||
update: '/DoctorMgmt/ApproveDoctor',
|
||||
delete: '/DoctorMgmt',
|
||||
approve: '/DoctorMgmt/ApproveDoctor',
|
||||
getPending: '/DoctorMgmt/GetPendingApprovals',
|
||||
},
|
||||
},
|
||||
NSM: {
|
||||
doctor: {
|
||||
getAll: '/DoctorMgmt/GetPendingApprovals',
|
||||
getById: '/DoctorMgmt/GetDoctor',
|
||||
getSpecialization:'/Maintenance/Specialization',
|
||||
create: '/DoctorMgmt/ApproveDoctor',
|
||||
update: '/DoctorMgmt/ApproveDoctor',
|
||||
delete: '/DoctorMgmt',
|
||||
approve: '/DoctorMgmt/ApproveDoctor',
|
||||
getPending: '/DoctorMgmt/GetPendingApprovals',
|
||||
},
|
||||
},
|
||||
ADMIN: {
|
||||
doctor: {
|
||||
getAll: '/Admin/GetAllDoctors',
|
||||
getById: '/Admin/GetDoctor',
|
||||
getSpecialization:'/Maintenance/Specialization',
|
||||
create: '/Admin/CreateDoctor',
|
||||
update: '/Admin/UpdateDoctor',
|
||||
delete: '/Admin/DeleteDoctor',
|
||||
approve: '/Admin/ApproveDoctor',
|
||||
getPending: '/Admin/GetPendingApprovals',
|
||||
},
|
||||
}
|
||||
};
|
||||
// Default endpoints
|
||||
export const DEFAULT_ENDPOINTS: RoleEndpoints = {
|
||||
doctor: {
|
||||
getAll: '/DoctorMgmt/GetDoctor',
|
||||
getById: '/DoctorMgmt/GetDoctor',
|
||||
getSpecialization:'/Maintenance/Specialization',
|
||||
create: '/DoctorMgmt/PostPutDoctor',
|
||||
update: '/DoctorMgmt/PostPutDoctor',
|
||||
delete: '/DoctorMgmt',
|
||||
approve: '/DoctorMgmt/ApproveDoctor',
|
||||
getPending: '/DoctorMgmt/GetPendingApprovals',
|
||||
}
|
||||
};
|
||||
export const config: DefaultEndpoints = {
|
||||
institution: {
|
||||
getAll: '/Maintenance/Institutions',
|
||||
getById: '/Maintenance/Institutions',
|
||||
create: '/Maintenance/PostPutInstitution',
|
||||
update: '/Maintenance/PostPutInstitution',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
areas: {
|
||||
getAll: '/Maintenance/Areas',
|
||||
getById: '/Maintenance/Areas',
|
||||
create: '/Maintenance/PostPutArea',
|
||||
update: '/Maintenance/PostPutArea',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
district: {
|
||||
getAll: '/Maintenance/Districts',
|
||||
getById: '/Maintenance/Districts',
|
||||
create: '/Maintenance/PostPutDistrict',
|
||||
update: '/Maintenance/PostPutDistrict',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
division: {
|
||||
getAll: '/Maintenance/Divisions',
|
||||
getById: '/Maintenance/Divisions',
|
||||
create: '/Maintenance/PostPutDivision',
|
||||
update: '/Maintenance/PostPutDivision',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
territory: {
|
||||
getAll: '/Maintenance/Territories',
|
||||
getById: '/Maintenance/Territories',
|
||||
create: '/Maintenance/PostPutTerritory',
|
||||
update: '/Maintenance/PostPutTerritory',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
product:{
|
||||
getAll: '/Maintenance/Products',
|
||||
getById: '/Maintenance/Products',
|
||||
getFile: '/Maintenance/productFile',
|
||||
create: '/Maintenance/PostPutProduct',
|
||||
update: '/Maintenance/PostPutProduct',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
inventory:{
|
||||
getAll: '/Inventory/Inventory',
|
||||
getById: '/Inventory/Inventory',
|
||||
getFile: '/Inventory/InventoryFile',
|
||||
create: '/Inventory/PostPutInventory',
|
||||
update: '/Inventory/PostPutInventory',
|
||||
delete: '/Inventory/DeleteInventory',
|
||||
bulkDelete:'/Inventory/BulkDeleteInventory',
|
||||
uploadExcel: '/Inventory/UploadExcel'
|
||||
},
|
||||
institutionCategory:{
|
||||
getAll: '/Maintenance/InstitutionCategories',
|
||||
getById: '/Maintenance/InstitutionCategories',
|
||||
create: '/Maintenance/PostPutInstitutionCategory',
|
||||
update: '/Maintenance/PostPutInstitutionCategory',
|
||||
delete: '/Maintenance'
|
||||
},
|
||||
userAccount:{
|
||||
getAll: '/Account/GetUsers',
|
||||
getById: '/Account/GetUsers',
|
||||
create: '/Account/Register',
|
||||
update: '/Account/UpdateUser',
|
||||
delete: '/Account'
|
||||
},
|
||||
department:{
|
||||
getAll: '/Account/GetDepartment',
|
||||
getById: '/Account/GetDepartment',
|
||||
create: '/Account/PostPutDepartment',
|
||||
update: '/Account/PostPutDepartment',
|
||||
delete: '/Account'
|
||||
},
|
||||
userRole:{
|
||||
getAll: '/Account/GetUserRole',
|
||||
getById: '/Account/GetUserRole',
|
||||
create: '/Account/PostPutUserRole',
|
||||
update: '/Account/PostPutUserRole',
|
||||
delete: '/Account'
|
||||
},
|
||||
appointment:{
|
||||
getAll: '/Plan/GetDoctorWithPlan',
|
||||
getById: '/Plan/GetDoctorById',
|
||||
getPendingApprovals: '/Plan/GetPendingApprovals',
|
||||
getProductTransaction:'/Plan/GetProductTransaction',
|
||||
getSampleTransaction:'/Plan/GetSampleTransaction',
|
||||
create: '/Plan/PostPutPlan',
|
||||
update: '/Plan/PostPutPlan',
|
||||
approve: '/Plan/ApprovePlan',
|
||||
copyMonthlyMRRawPlans: '/Plan/CopyMonthlyMRRawPlans',
|
||||
},
|
||||
reports:{
|
||||
getCallRateReport: '/Reports/GetCallRateReport',
|
||||
getCallReachReport: '/Reports/GetCallReachReport',
|
||||
getCatchUpCallReport: '/Reports/GetCatchUpCallReport',
|
||||
getActualCoverageReport: '/Reports/GetActualCoverageReport',
|
||||
getDoctorSignature: '/Reports/GetDoctorSignature',
|
||||
getAttendanceLogs: '/TimeKeeping/GetAttendanceLogs',
|
||||
getAttendancePhoto: '/TimeKeeping/GetAttendancePhoto',
|
||||
create: '/Reports/PutMissedCall',
|
||||
update: '/Reports/PutMissedCall'
|
||||
},
|
||||
dashboard:{
|
||||
getAllDoctor: '/DoctorMgmt/GetAllDoctor',
|
||||
getMyDoctor: '/DoctorMgmt/GetMyDoctor',
|
||||
getAllAppointmentToday: '/Plan/GetAllAppointmentToday',
|
||||
getAttendanceToday: '/TimeKeeping/GetAttendanceToday',
|
||||
getAllMissReschedule:'/Plan/GetAllMissReschedule'
|
||||
}
|
||||
}
|
||||
171
src/config/roleEndpoints.ts
Normal file
171
src/config/roleEndpoints.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { DEFAULT_ENDPOINTS, ROLE_ENDPOINTS } from "./endpoints";
|
||||
|
||||
export interface RoleEndpoints {
|
||||
doctor: {
|
||||
getAll: string;
|
||||
getById: string;
|
||||
getSpecialization: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
approve: string;
|
||||
getPending: string;
|
||||
}
|
||||
}
|
||||
export interface DefaultEndpoints {
|
||||
institution:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
areas:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
district:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
division:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
product:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
getFile: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
} ,
|
||||
inventory:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
getFile: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
bulkDelete:string;
|
||||
uploadExcel:string;
|
||||
} ,
|
||||
territory: {
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
institutionCategory:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
userAccount:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
department:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
userRole:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
},
|
||||
appointment:{
|
||||
getAll: string;
|
||||
getById: string;
|
||||
getPendingApprovals:string;
|
||||
getProductTransaction:string;
|
||||
getSampleTransaction:string;
|
||||
create: string;
|
||||
update: string;
|
||||
approve: string;
|
||||
copyMonthlyMRRawPlans: string;
|
||||
},
|
||||
reports:{
|
||||
getCallRateReport:string;
|
||||
getCallReachReport: string;
|
||||
getCatchUpCallReport: string;
|
||||
getActualCoverageReport: string;
|
||||
getDoctorSignature: string;
|
||||
getAttendanceLogs:string;
|
||||
getAttendancePhoto:string;
|
||||
create:string;
|
||||
update: string;
|
||||
},
|
||||
dashboard:{
|
||||
getAllDoctor:string;
|
||||
getMyDoctor:string;
|
||||
getAllAppointmentToday:string;
|
||||
getAttendanceToday:string;
|
||||
getAllMissReschedule:string;
|
||||
};
|
||||
}
|
||||
export function getDefaultEndpoint(
|
||||
module: keyof DefaultEndpoints
|
||||
): string {
|
||||
|
||||
// Get the endpoint from the module
|
||||
const endpoint = module;
|
||||
|
||||
if (!endpoint) {
|
||||
console.error(`Module "${module}" not found in role endpoints`);
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
console.error(`Operation "${endpoint}" not found`);
|
||||
return '';
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
export function getEndpoint(
|
||||
module: keyof RoleEndpoints,
|
||||
operation: string,
|
||||
userRole?: string
|
||||
): string {
|
||||
const role = userRole || 'MEDREP'; // Default role
|
||||
const roleEndpoints = ROLE_ENDPOINTS[role] || DEFAULT_ENDPOINTS;
|
||||
|
||||
// Get the endpoint from the module
|
||||
const moduleEndpoints = roleEndpoints[module];
|
||||
|
||||
if (!moduleEndpoints) {
|
||||
console.error(`Module "${module}" not found in role endpoints`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const endpoint = (moduleEndpoints as any)[operation];
|
||||
|
||||
if (!endpoint) {
|
||||
console.error(`Operation "${operation}" not found for module "${module}" and role "${role}"`);
|
||||
console.log('Available operations:', Object.keys(moduleEndpoints));
|
||||
return '';
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
1
src/constants/index.ts
Normal file
1
src/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './navigation';
|
||||
27
src/constants/navigation.ts
Normal file
27
src/constants/navigation.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
User,
|
||||
Calendar,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const navigationItems: NavigationItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Home, color: 'text-blue-600' },
|
||||
{ id: 'doctors', label: 'Doctor Management', icon: Users, badge: '3', color: 'text-teal-600' },
|
||||
{ id: 'patients', label: 'Patients', icon: User, badge: '156', color: 'text-purple-600' },
|
||||
{ id: 'appointments', label: 'Appointments', icon: Calendar, badge: '12', color: 'text-green-600' },
|
||||
{ id: 'departments', label: 'Departments', icon: Building2, color: 'text-orange-600' },
|
||||
{ id: 'reports', label: 'Reports', icon: BarChart3, color: 'text-red-600' },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, color: 'text-gray-600' },
|
||||
];
|
||||
91
src/contexts/AuthContext.tsx
Normal file
91
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React, { createContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthContextType, AuthUser } from '../types/auth';
|
||||
|
||||
// Create the context
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const isAuthenticated = !!user && !!user.token;
|
||||
|
||||
useEffect(() => {
|
||||
// Check for stored authentication data on app load
|
||||
const storedAuth = localStorage.getItem('auth');
|
||||
if (storedAuth) {
|
||||
try {
|
||||
const authData = JSON.parse(storedAuth);
|
||||
|
||||
// Check if token is still valid (you might want to add token expiration check)
|
||||
if (authData.token && authData.userName) {
|
||||
setUser(authData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('auth');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (authData: {
|
||||
company: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
fullName: string;
|
||||
userClaims: any[];
|
||||
userRole: string;
|
||||
expiration?: string;
|
||||
}) => {
|
||||
const userData: AuthUser = {
|
||||
userId: authData.userId,
|
||||
userName: authData.userName,
|
||||
fullName: authData.fullName,
|
||||
userRole: authData.userRole,
|
||||
userClaims: authData.userClaims,
|
||||
token: authData.token,
|
||||
company: authData.company,
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
|
||||
// Store authentication data in localStorage
|
||||
localStorage.setItem('auth', JSON.stringify(userData));
|
||||
|
||||
// Set default authorization header for future API calls
|
||||
// You might want to do this in your API service instead
|
||||
if (typeof window !== 'undefined') {
|
||||
// Set token for future API calls
|
||||
localStorage.setItem('token', authData.token);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('auth');
|
||||
localStorage.removeItem('token');
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
logout
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
40
src/contexts/ToastContext.tsx
Normal file
40
src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { ToastType, ToastContextType } from '../types/toast';
|
||||
|
||||
export const ToastContext = createContext<ToastContextType>({
|
||||
toasts: [],
|
||||
addToast: () => {},
|
||||
removeToast: () => {},
|
||||
});
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastType[]>([]);
|
||||
|
||||
const addToast = (toast: Omit<ToastType, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newToast = { ...toast, id };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, toast.duration || 5000);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
1
src/contexts/index.ts
Normal file
1
src/contexts/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ToastContext';
|
||||
30
src/dto/planningDtos.ts
Normal file
30
src/dto/planningDtos.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export interface PlanningDto {
|
||||
SelectedSamples: SelectedSampleDto[];
|
||||
SelectedPromos: SelectedPromoDto[];
|
||||
MRPlanDetailId?: string | null;
|
||||
MaxVisit: number;
|
||||
DoctorId: number;
|
||||
PlanDate: string;
|
||||
UserId?: string | null;
|
||||
MRRawPlanId?: string | null;
|
||||
ProductId: number;
|
||||
CallDate: string;
|
||||
IsLiterature: boolean;
|
||||
CycleId: number;
|
||||
SLP?: string | null;
|
||||
IsUpdateDelete: number;
|
||||
MRPlanDate?: string | null;
|
||||
UpdatedBy?: string | null;
|
||||
}
|
||||
export interface SelectedSampleDto {
|
||||
InventoryId: string;
|
||||
Description:string;
|
||||
QtyBalance: number;
|
||||
SelectedQty: number;
|
||||
OriginalQty: number;
|
||||
MaxQty: number;
|
||||
}
|
||||
interface SelectedPromoDto {
|
||||
ProductId: number;
|
||||
ProductName:string;
|
||||
}
|
||||
18
src/hooks/doctor/useSpecializationApi.ts
Normal file
18
src/hooks/doctor/useSpecializationApi.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { DoctorAPI } from "../../services/doctorApi";
|
||||
import { Specialization } from "../../types/doctor/generic";
|
||||
|
||||
export const useSpecializationApi = () => {
|
||||
const [specializations, setSpecializations] = useState<Specialization[]>([]);
|
||||
|
||||
const fetchSpecializations = async () => {
|
||||
try {
|
||||
const result = await DoctorAPI.getSpecialization();
|
||||
setSpecializations(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching specializations", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { specializations, setSpecializations, fetchSpecializations };
|
||||
};
|
||||
18
src/hooks/generic/useArea.ts
Normal file
18
src/hooks/generic/useArea.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Areas } from "../../types/doctor/generic";
|
||||
import { AreasAPI } from "../../services/generic/areasApi";
|
||||
|
||||
export const useAreasApi = () => {
|
||||
const [areas, setAreas] = useState<Areas[]>([]);
|
||||
|
||||
const fetchAreas = async () => {
|
||||
try {
|
||||
const result = await AreasAPI.getAll();
|
||||
setAreas(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { areas, setAreas, fetchAreas };
|
||||
};
|
||||
18
src/hooks/generic/useDepartment.ts
Normal file
18
src/hooks/generic/useDepartment.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Department } from "../../types/doctor/generic";
|
||||
import { DepartmentAPI } from "../../services/generic/departmentApi";
|
||||
|
||||
export const useDepartmentApi = () => {
|
||||
const [department, setDepartment] = useState<Department[]>([]);
|
||||
|
||||
const fetchDepartment = async () => {
|
||||
try {
|
||||
const result = await DepartmentAPI.getAll();
|
||||
setDepartment(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching department", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { department, setDepartment, fetchDepartment };
|
||||
};
|
||||
18
src/hooks/generic/useDistrict.ts
Normal file
18
src/hooks/generic/useDistrict.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { District } from "../../types/doctor/generic";
|
||||
import { DistrictAPI } from "../../services/generic/districtApi";
|
||||
|
||||
export const useDistrictApi = () => {
|
||||
const [districts, setDistricts] = useState<District[]>([]);
|
||||
|
||||
const fetchDistricts = async () => {
|
||||
try {
|
||||
const result = await DistrictAPI.getAll();
|
||||
setDistricts(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { districts, setDistricts, fetchDistricts };
|
||||
};
|
||||
18
src/hooks/generic/useDivision.ts
Normal file
18
src/hooks/generic/useDivision.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Division } from "../../types/doctor/generic";
|
||||
import { DivisionAPI } from "../../services/generic/divisionApi";
|
||||
|
||||
export const useDivisionApi = () => {
|
||||
const [divisions, setDivisions] = useState<Division[]>([]);
|
||||
|
||||
const fetchDivisions = async () => {
|
||||
try {
|
||||
const result = await DivisionAPI.getAll();
|
||||
setDivisions(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { divisions, setDivisions, fetchDivisions };
|
||||
};
|
||||
18
src/hooks/generic/useInstitutionApi.ts
Normal file
18
src/hooks/generic/useInstitutionApi.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Institution } from "../../types/doctor/generic";
|
||||
import { InstitutionAPI } from "../../services/generic/institutionApi";
|
||||
|
||||
export const useInstitutionApi = () => {
|
||||
const [institutions, setInstitutions] = useState<Institution[]>([]);
|
||||
|
||||
const fetchInstitutions = async () => {
|
||||
try {
|
||||
const result = await InstitutionAPI.getAll();
|
||||
setInstitutions(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching institutions", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { institutions, setInstitutions, fetchInstitutions };
|
||||
};
|
||||
18
src/hooks/generic/useInstitutionCategory.ts
Normal file
18
src/hooks/generic/useInstitutionCategory.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { InstitutionCategory } from "../../types/doctor/generic";
|
||||
import { InstitutionCategoryAPI } from "../../services/generic/institutionCategoryApi";
|
||||
|
||||
export const useInstitutionCategoryApi = () => {
|
||||
const [institutionCategory, setInstitutionCategory] = useState<InstitutionCategory[]>([]);
|
||||
|
||||
const fetchInstitutionCategory = async () => {
|
||||
try {
|
||||
const result = await InstitutionCategoryAPI.getAll();
|
||||
setInstitutionCategory(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { institutionCategory, setInstitutionCategory, fetchInstitutionCategory };
|
||||
};
|
||||
18
src/hooks/generic/useInventory.ts
Normal file
18
src/hooks/generic/useInventory.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Inventory } from "../../types/doctor/generic";
|
||||
import { InventoryAPI } from "../../services/generic/inventoryApi";
|
||||
|
||||
export const useInventoryApi = () => {
|
||||
const [inventory, setInventory] = useState<Inventory[]>([]);
|
||||
|
||||
const fetchInventory = async () => {
|
||||
try {
|
||||
const result = await InventoryAPI.getAll();
|
||||
setInventory(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching inventory", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { inventory, setInventory, fetchInventory };
|
||||
};
|
||||
18
src/hooks/generic/useProduct.ts
Normal file
18
src/hooks/generic/useProduct.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Product } from "../../types/doctor/generic";
|
||||
import { ProductAPI } from "../../services/generic/productApi";
|
||||
|
||||
export const useProductApi = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const result = await ProductAPI.getAll();
|
||||
setProducts(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { products, setProducts, fetchProducts };
|
||||
};
|
||||
18
src/hooks/generic/useTerritoryApi.ts
Normal file
18
src/hooks/generic/useTerritoryApi.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { TerritoryAPI } from "../../services/generic/territoryApi";
|
||||
import { Territories } from "../../types/doctor/generic";
|
||||
|
||||
export const useTerritoryApi = () => {
|
||||
const [territory, setTerritory] = useState<Territories[]>([]);
|
||||
|
||||
const fetchTerritory = async () => {
|
||||
try {
|
||||
const result = await TerritoryAPI.getAll();
|
||||
setTerritory(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Territories", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { territory, setTerritory, fetchTerritory };
|
||||
};
|
||||
18
src/hooks/generic/useUserAccountApi.ts
Normal file
18
src/hooks/generic/useUserAccountApi.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { UserAccount } from "../../types/doctor/generic";
|
||||
import { UserAccountAPI } from "../../services/generic/userAccountApi";
|
||||
|
||||
export const useUserAccountApi = () => {
|
||||
const [userAccounts, setUserAccounts] = useState<UserAccount[]>([]);
|
||||
|
||||
const fetchUserAccounts = async () => {
|
||||
try {
|
||||
const result = await UserAccountAPI.getAll();
|
||||
setUserAccounts(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching UserAccount", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { userAccounts, setUserAccounts, fetchUserAccounts };
|
||||
};
|
||||
18
src/hooks/generic/useUserRole.ts
Normal file
18
src/hooks/generic/useUserRole.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { UserRoles } from "../../types/doctor/generic";
|
||||
import { UserRoleAPI } from "../../services/generic/userRoleApi";
|
||||
|
||||
export const useUserRoleApi = () => {
|
||||
const [userRole, setUserRole] = useState<UserRoles[]>([]);
|
||||
|
||||
const fetchUserRole = async () => {
|
||||
try {
|
||||
const result = await UserRoleAPI.getAll();
|
||||
setUserRole(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching userRole", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { userRole, setUserRole, fetchUserRole };
|
||||
};
|
||||
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './useApi';
|
||||
18
src/hooks/reports/useCallRateApi.ts
Normal file
18
src/hooks/reports/useCallRateApi.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Institution } from "../../types/doctor/generic";
|
||||
import { InstitutionAPI } from "../../services/generic/institutionApi";
|
||||
|
||||
export const useInstitutionApi = () => {
|
||||
const [institutions, setInstitutions] = useState<Institution[]>([]);
|
||||
|
||||
const fetchInstitutions = async () => {
|
||||
try {
|
||||
const result = await InstitutionAPI.getAll();
|
||||
setInstitutions(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching institutions", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { institutions, setInstitutions, fetchInstitutions };
|
||||
};
|
||||
33
src/hooks/useApi.ts
Normal file
33
src/hooks/useApi.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export const useApi = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const apiCall = async <T>(
|
||||
apiFunction: () => Promise<T>,
|
||||
successMessage?: string,
|
||||
errorMessage?: string
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
const result = await apiFunction();
|
||||
if (successMessage) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
message: successMessage
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: errorMessage || 'An error occurred'
|
||||
});
|
||||
console.error('API Error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return { apiCall };
|
||||
};
|
||||
18
src/hooks/useAppointment.ts
Normal file
18
src/hooks/useAppointment.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { District } from "../types/doctor/generic";
|
||||
import { DistrictAPI } from "../services/generic/districtApi";
|
||||
|
||||
export const useAppointmentApi = () => {
|
||||
const [appointments, setDistricts] = useState<District[]>([]);
|
||||
|
||||
const fetchAppointments = async () => {
|
||||
try {
|
||||
const result = await DistrictAPI.getAll();
|
||||
setDistricts(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Districts", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { appointments, fetchAppointments };
|
||||
};
|
||||
13
src/hooks/useAuth.ts
Normal file
13
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,13 @@
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '../contexts/AuthContext';
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
39
src/hooks/useNavigation.ts
Normal file
39
src/hooks/useNavigation.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { navigationService } from '../services/navigationService';
|
||||
import { NavigationItem } from '../types/navigation';
|
||||
|
||||
export const useNavigation = () => {
|
||||
const [navigationItems, setNavigationItems] = useState<NavigationItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNavigation = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await navigationService.getUserNavigation();
|
||||
|
||||
if (response.success) {
|
||||
setNavigationItems(response.data);
|
||||
} else {
|
||||
setError(response.message || 'Failed to load navigation');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error loading navigation');
|
||||
console.error('Navigation fetch error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNavigation();
|
||||
}, []);
|
||||
|
||||
const refetchNavigation = async () => {
|
||||
await refetchNavigation();
|
||||
};
|
||||
|
||||
return { navigationItems, isLoading, error, refetchNavigation };
|
||||
};
|
||||
11
src/hooks/useToast.ts
Normal file
11
src/hooks/useToast.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
import { ToastContext } from '../contexts/ToastContext';
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
48
src/index.css
Normal file
48
src/index.css
Normal file
@ -0,0 +1,48 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* Import Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all elements */
|
||||
* {
|
||||
transition-property:
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke,
|
||||
opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Custom component styles */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.teal-gradient {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #134e4a 100%);
|
||||
}
|
||||
18
src/main.tsx
Normal file
18
src/main.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
// Import your existing contexts
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
161
src/pages/Dashboard.tsx
Normal file
161
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users,
|
||||
User,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
Clock,
|
||||
UserPlus,
|
||||
FileText,
|
||||
Award
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Button } from '../components/ui';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const stats = [
|
||||
{ title: 'Total Doctors', value: '48', change: '+12%', color: 'teal', icon: Users },
|
||||
{ title: 'Appointments Today', value: '253', change: '+15%', color: 'green', icon: Calendar },
|
||||
{ title: 'Reschedule', value: '255543', change: '+8%', color: 'blue', icon: User },
|
||||
{ title: 'Missed call', value: '$45,291', change: '+5%', color: 'purple', icon: TrendingUp }
|
||||
];
|
||||
|
||||
const recentActivities = [
|
||||
{ id: 1, action: 'New patient registered', user: 'Sarah Johnson', time: '2 minutes ago', type: 'user' },
|
||||
{ id: 2, action: 'Appointment scheduled', user: 'Dr. Smith', time: '15 minutes ago', type: 'calendar' },
|
||||
{ id: 3, action: 'Lab report uploaded', user: 'Lab Tech Mike', time: '1 hour ago', type: 'file' },
|
||||
{ id: 4, action: 'New doctor added', user: 'HR Manager', time: '2 hours ago', type: 'user-plus' },
|
||||
];
|
||||
|
||||
const upcomingAppointments = [
|
||||
{ id: 1, patient: 'John Doe', doctor: 'Dr. Smith', time: '09:00 AM', status: 'confirmed' },
|
||||
{ id: 2, patient: 'Jane Smith', doctor: 'Dr. Johnson', time: '10:30 AM', status: 'pending' },
|
||||
{ id: 3, patient: 'Mike Brown', doctor: 'Dr. Wilson', time: '02:00 PM', status: 'confirmed' },
|
||||
{ id: 4, patient: 'Lisa Davis', doctor: 'Dr. Smith', time: '03:30 PM', status: 'confirmed' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={index} className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-${stat.color}-100 rounded-lg flex items-center justify-center`}>
|
||||
<Icon className={`w-6 h-6 text-${stat.color}-600`} />
|
||||
</div>
|
||||
<span className={`text-sm font-medium text-${stat.color}-600 bg-${stat.color}-50 px-2 py-1 rounded-full`}>
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">{stat.title}</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activities */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Activities</h3>
|
||||
<Activity className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className="w-8 h-8 bg-teal-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-4 h-4 text-teal-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{activity.action}</p>
|
||||
<p className="text-sm text-gray-500">by {activity.user}</p>
|
||||
<p className="text-xs text-gray-400">{activity.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="w-full mt-4 text-sm text-teal-600 hover:text-teal-700 font-medium">
|
||||
View All Activities
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Today's Appointments</h3>
|
||||
<Calendar className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{upcomingAppointments.map((appointment) => (
|
||||
<div key={appointment.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{appointment.patient}</p>
|
||||
<p className="text-xs text-gray-500">{appointment.doctor}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">{appointment.time}</p>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
appointment.status === 'confirmed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{appointment.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="w-full mt-4 text-sm text-teal-600 hover:text-teal-700 font-medium">
|
||||
View All Appointments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Quick Actions</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addToast({ type: 'info', title: 'Feature Coming Soon!', message: 'This feature will be available soon.' })}
|
||||
className="flex flex-col items-center p-6 h-auto"
|
||||
>
|
||||
<UserPlus className="w-8 h-8 mb-2" />
|
||||
<span>Add Patient</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addToast({ type: 'info', title: 'Feature Coming Soon!', message: 'This feature will be available soon.' })}
|
||||
className="flex flex-col items-center p-6 h-auto"
|
||||
>
|
||||
<Calendar className="w-8 h-8 mb-2" />
|
||||
<span>Schedule</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addToast({ type: 'info', title: 'Feature Coming Soon!', message: 'This feature will be available soon.' })}
|
||||
className="flex flex-col items-center p-6 h-auto"
|
||||
>
|
||||
<FileText className="w-8 h-8 mb-2" />
|
||||
<span>Reports</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addToast({ type: 'info', title: 'Feature Coming Soon!', message: 'This feature will be available soon.' })}
|
||||
className="flex flex-col items-center p-6 h-auto"
|
||||
>
|
||||
<Award className="w-8 h-8 mb-2" />
|
||||
<span>Awards</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
src/pages/SettingsPage.tsx
Normal file
115
src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { InstitutionTab } from './tabs/InstitutionTab';
|
||||
import { DistrictTab } from './tabs/DistrictTab';
|
||||
import { ProductTab } from './tabs/ProductTab';
|
||||
import { DivisionTab } from './tabs/DivisionTab';
|
||||
import { AreaTab } from './tabs/AreaTab';
|
||||
import { TerritoryTab } from './tabs/TerritoryTab';
|
||||
import { InstitutionCategoryTab } from './tabs/InstitutionCategoryTab';
|
||||
import { UserAccountTab } from './tabs/UserAccountTab';
|
||||
import { DepartmentTab } from './tabs/DepartmentTab';
|
||||
import { ApiHelpers } from '../utils/apiHelpers';
|
||||
import { InventoryTab } from './tabs/InventoryTab';
|
||||
|
||||
interface SettingsPageProps {
|
||||
user: any;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| 'account'
|
||||
| 'inventory'
|
||||
| 'department'
|
||||
| 'institutions'
|
||||
| 'districts'
|
||||
| 'divisions'
|
||||
| 'products'
|
||||
| 'areas'
|
||||
| 'territories'
|
||||
| 'institutionCategory';
|
||||
|
||||
export const SettingsPage: React.FC<SettingsPageProps> = () => {
|
||||
const userRole = (ApiHelpers.getUserRole() || 'MEDREP').toUpperCase();
|
||||
|
||||
const allTabs = [
|
||||
{ id: 'institutionCategory' as TabType, label: 'Category', icon: '🗂️' },
|
||||
{ id: 'institutions' as TabType, label: 'Institutions', icon: '🏫' },
|
||||
{ id: 'districts' as TabType, label: 'Districts', icon: '📍' },
|
||||
{ id: 'divisions' as TabType, label: 'Divisions', icon: '🏢' },
|
||||
{ id: 'areas' as TabType, label: 'Areas', icon: '📌' },
|
||||
{ id: 'territories' as TabType, label: 'Territory', icon: '🌐' },
|
||||
{ id: 'products' as TabType, label: 'Products', icon: '📦' },
|
||||
{ id: 'department' as TabType, label: 'Department', icon: '🧑' },
|
||||
{ id: 'inventory' as TabType, label: 'Inventory', icon: '📦' },
|
||||
{ id: 'account' as TabType, label: 'Account', icon: '👤' },
|
||||
];
|
||||
|
||||
const visibleTabs = allTabs.filter(
|
||||
(tab) =>
|
||||
tab.id !== 'account' ||
|
||||
['ADMIN', 'NSM', 'DSM'].includes(userRole)
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>(
|
||||
visibleTabs[0]?.id || 'institutionCategory'
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'account':
|
||||
if (['ADMIN', 'NSM', 'DSM'].includes(userRole)) {
|
||||
return <UserAccountTab />;
|
||||
}
|
||||
return null;
|
||||
case 'inventory':
|
||||
return <InventoryTab />;
|
||||
case 'department':
|
||||
return <DepartmentTab />;
|
||||
case 'institutionCategory':
|
||||
return <InstitutionCategoryTab />;
|
||||
case 'institutions':
|
||||
return <InstitutionTab />;
|
||||
case 'districts':
|
||||
return <DistrictTab />;
|
||||
case 'divisions':
|
||||
return <DivisionTab />;
|
||||
case 'products':
|
||||
return <ProductTab />;
|
||||
case 'areas':
|
||||
return <AreaTab />;
|
||||
case 'territories':
|
||||
return <TerritoryTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex overflow-x-auto">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center px-6 py-4 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">{renderTabContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1889
src/pages/appointment/AppointmentManagement.tsx
Normal file
1889
src/pages/appointment/AppointmentManagement.tsx
Normal file
File diff suppressed because it is too large
Load Diff
388
src/pages/doctor/DoctorManagement.tsx
Normal file
388
src/pages/doctor/DoctorManagement.tsx
Normal file
@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Download, User} from 'lucide-react';
|
||||
import { Doctor } from '../../types/doctor/doctor';
|
||||
import { DoctorAPI } from '../../services/doctorApi';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useApi } from '../../hooks/useApi';
|
||||
import { Button, Modal, ConfirmModal, ToastContainer, LoadingSpinner } from '../../components/ui';
|
||||
import { DoctorForm, DoctorCard, DoctorDetailView } from '../../components/doctor';
|
||||
|
||||
export const DoctorManagement: React.FC = () => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [selectedMedRep, setSelectedMedRep] = useState('all');
|
||||
|
||||
// Modal states
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
|
||||
const { addToast } = useToast();
|
||||
const { apiCall } = useApi();
|
||||
|
||||
// Load doctors on component mount
|
||||
useEffect(() => {
|
||||
loadDoctors();
|
||||
}, []);
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await DoctorAPI.getAll();
|
||||
// console.log('data',data);
|
||||
setDoctors(data);
|
||||
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to load doctors'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Filter and sort doctors
|
||||
const filteredDoctors = doctors
|
||||
.filter((doctor) => {
|
||||
const matchesSearch = `${doctor.firstName ?? ''} ${doctor.lastName ?? ''} ${doctor.emailAddress ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus =
|
||||
filterStatus === 'all' || doctor.status.toString() === filterStatus;
|
||||
|
||||
const matchesMedRep =
|
||||
selectedMedRep === 'all' || doctor.medRepName === selectedMedRep;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesMedRep;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return `${a.firstName ?? ''} ${a.lastName ?? ''}`.localeCompare(
|
||||
`${b.firstName ?? ''} ${b.lastName ?? ''}`
|
||||
);
|
||||
|
||||
case 'medRepName':
|
||||
return (a.medRepName ?? '').localeCompare(b.medRepName ?? '');
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// CRUD Operations
|
||||
const handleAddDoctor = async (doctorData: Omit<Doctor, 'doctorId'>) => {
|
||||
try {
|
||||
setFormLoading(true);
|
||||
const newDoctor = await DoctorAPI.create(doctorData);
|
||||
setDoctors(prev => [...prev, newDoctor]);
|
||||
setShowAddModal(false);
|
||||
await loadDoctors();
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
message: `Dr. ${doctorData.firstName} ${doctorData.lastName} has been added successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to add doctor'
|
||||
});
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateDoctor = async (doctorData: Omit<Doctor, 'doctorId'>) => {
|
||||
if (!selectedDoctor) return;
|
||||
|
||||
setFormLoading(true);
|
||||
console.log('update',doctorData.status);
|
||||
const updatedDoctor = await apiCall(
|
||||
() => DoctorAPI.update(selectedDoctor.doctorId, doctorData),
|
||||
`Dr. ${doctorData.firstName} ${doctorData.lastName} has been updated successfully`,
|
||||
'Failed to update doctor'
|
||||
);
|
||||
|
||||
if (updatedDoctor) {
|
||||
await loadDoctors();
|
||||
setShowEditModal(false);
|
||||
setSelectedDoctor(null);
|
||||
}
|
||||
|
||||
setFormLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteDoctor = async () => {
|
||||
if (!selectedDoctor) return;
|
||||
|
||||
try {
|
||||
await DoctorAPI.delete(selectedDoctor.doctorId);
|
||||
setDoctors(prev => prev.filter(doc => doc.doctorId !== selectedDoctor.doctorId));
|
||||
setSelectedDoctor(null);
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
message: `Dr. ${selectedDoctor.firstName} ${selectedDoctor.lastName} has been deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to delete doctor'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Action handlers
|
||||
const handleView = (doctor: Doctor) => {
|
||||
setSelectedDoctor(doctor);
|
||||
setShowViewModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (doctor: Doctor) => {
|
||||
setSelectedDoctor(doctor);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (doctor: Doctor) => {
|
||||
setSelectedDoctor(doctor);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
const csvContent = [
|
||||
['First Name', 'Last Name', 'Email', 'Phone', 'Specialization', 'License', 'Status'].join(','),
|
||||
...filteredDoctors.map(doctor => [
|
||||
doctor.firstName,
|
||||
doctor.lastName,
|
||||
doctor.emailAddress,
|
||||
doctor.phoneNo,
|
||||
doctor.specializationId,
|
||||
doctor.licenseNo,
|
||||
doctor.status
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'doctors.csv';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Export Successful',
|
||||
message: 'Doctors data exported to CSV file'
|
||||
});
|
||||
};
|
||||
|
||||
const medRepOptions = [
|
||||
...new Set(doctors.map(d => d.medRepName).filter(Boolean))
|
||||
];
|
||||
if (loading) {
|
||||
return <LoadingSpinner message="Loading please wait..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Doctor Management</h1>
|
||||
<p className="text-gray-600">Manage your medical staff efficiently</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add New Doctor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 lg:space-x-4">
|
||||
<div className="flex-1 flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search doctors..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-12 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="0">Denied</option>
|
||||
<option value="1">For DSM Approval</option>
|
||||
<option value="2">For NSM Approval</option>
|
||||
<option value="3">Approved</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="specializationId">Sort by Specialization</option>
|
||||
{/* <option value="hireDate">Sort by Hire Date</option>*/}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedMedRep}
|
||||
onChange={(e) => setSelectedMedRep(e.target.value)}
|
||||
className="px-12 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All MedReps</option>
|
||||
|
||||
{medRepOptions.map((rep) => (
|
||||
<option key={rep} value={rep}>
|
||||
{rep}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={exportToCSV}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-gray-600">
|
||||
Showing {filteredDoctors.length} of {doctors.length} doctors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Doctors Grid/List */}
|
||||
{filteredDoctors.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
||||
<User className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No doctors found</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{searchTerm || filterStatus !== 'all'
|
||||
? 'Try adjusting your search or filter criteria.'
|
||||
: 'Get started by adding your first doctor.'}
|
||||
</p>
|
||||
{!searchTerm && filterStatus === 'all' && (
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add First Doctor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredDoctors.map((doctor, index) => (
|
||||
<DoctorCard
|
||||
key={`doctor-${doctor.doctorId}-${index}`}
|
||||
doctor={doctor}
|
||||
onView={() => handleView(doctor)}
|
||||
onEdit={() => handleEdit(doctor)}
|
||||
onDelete={() => handleDelete(doctor)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
static={true}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
title="Add New Doctor"
|
||||
size="xl"
|
||||
>
|
||||
<DoctorForm
|
||||
onSubmit={handleAddDoctor}
|
||||
onCancel={() => setShowAddModal(false)}
|
||||
isLoading={formLoading}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
static={true}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedDoctor(null);
|
||||
}}
|
||||
title="Edit Doctor"
|
||||
size="xl"
|
||||
>
|
||||
{selectedDoctor && (
|
||||
<DoctorForm
|
||||
doctor={selectedDoctor}
|
||||
onSubmit={handleUpdateDoctor}
|
||||
onCancel={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedDoctor(null);
|
||||
}}
|
||||
isLoading={formLoading}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showViewModal}
|
||||
static={true}
|
||||
onClose={() => {
|
||||
setShowViewModal(false);
|
||||
setSelectedDoctor(null);
|
||||
}}
|
||||
title="Doctor Details"
|
||||
size="lg"
|
||||
>
|
||||
{selectedDoctor && <DoctorDetailView doctor={selectedDoctor} />}
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedDoctor(null);
|
||||
}}
|
||||
onConfirm={handleDeleteDoctor}
|
||||
title="Delete Doctor"
|
||||
message={`Are you sure you want to delete Dr. ${selectedDoctor?.firstName} ${selectedDoctor?.lastName}? This action cannot be undone.`}
|
||||
type="danger"
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
src/pages/doctor/Patients.tsx
Normal file
7
src/pages/doctor/Patients.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
import { ComingSoon } from '../../components/common';
|
||||
|
||||
export const PatientsPage: React.FC = () => {
|
||||
return <ComingSoon title="Patient Management" icon={User} />;
|
||||
};
|
||||
9
src/pages/doctor/index.ts
Normal file
9
src/pages/doctor/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// src/pages/index.ts
|
||||
export * from '../Dashboard';
|
||||
export * from './DoctorManagement';
|
||||
export * from './Patients';
|
||||
export * from '../appointment/AppointmentManagement';
|
||||
|
||||
// Add these new components
|
||||
export { DashboardOverview } from '../../components/dashboard/DashboardOverview';
|
||||
export { SettingsPage } from '../../pages/SettingsPage';
|
||||
160
src/pages/tabReports/ActualCoverageTab.tsx
Normal file
160
src/pages/tabReports/ActualCoverageTab.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ReportsTab } from '../../components/reports/ReportsTab';
|
||||
import { ActualCoverageAPI } from '../../services/reports/actualCoverageApi';
|
||||
import { ActualCoverage } from '@/types/reports/reports';
|
||||
import { formatDateTime } from '../../utils/formatDateTime';
|
||||
import { Modal } from '../../components/ui';
|
||||
|
||||
export const ActualCoverageTab: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signatureModal, setSignatureModal] = useState<{ isOpen: boolean; url: string | undefined }>({
|
||||
isOpen: false,
|
||||
url: undefined
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading ...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
|
||||
<><Modal
|
||||
isOpen={signatureModal.isOpen}
|
||||
onClose={() => setSignatureModal({ isOpen: false, url: undefined })}
|
||||
title="Doctor Signature"
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={signatureModal.url}
|
||||
alt="Doctor Signature"
|
||||
className="max-w-full max-h-64 object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; } } />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ReportsTab<ActualCoverage, string>
|
||||
title="Actual Coverage Report"
|
||||
idField="mrRawPlanId"
|
||||
showAddButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name..."
|
||||
showCheckbox={false}
|
||||
showActions={false}
|
||||
showPeriodSelector={true}
|
||||
onPeriodChange={(period: any, startDate: any, endDate: any) => {
|
||||
console.log('Period changed:', { period, startDate, endDate });
|
||||
} }
|
||||
columns={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MRName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'doctorName',
|
||||
label: 'DoctorName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'territoryName',
|
||||
label: 'Territory',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Company',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'callDate',
|
||||
label: 'Call_Date',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'actualCallDate',
|
||||
label: 'Actual_Call_Date',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'startTime',
|
||||
label: 'start_Time',
|
||||
searchable: true,
|
||||
render: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
key: 'endTime',
|
||||
label: 'end_Time',
|
||||
searchable: false,
|
||||
render: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
key: 'isCatchUpCall',
|
||||
label: 'Catch-Up Call',
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
render: (value) => (value ? 'Yes' : 'No'),
|
||||
getFilterValue: (item) => (item.isCatchUpCall ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
key: 'missedReason',
|
||||
label: 'missed_Reason',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'approvedBy',
|
||||
label: 'approved_By',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'approvedDate',
|
||||
label: 'approved_Date',
|
||||
searchable: false,
|
||||
render: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
key: 'signatureFileName',
|
||||
label: 'signature',
|
||||
render: (value) => value ? (
|
||||
<button
|
||||
onClick={() => setSignatureModal({ isOpen: true, url: ActualCoverageAPI.getFileUrl(value) })}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
View Signature
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">No Signature</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'location',
|
||||
searchable: false
|
||||
}
|
||||
]}
|
||||
api={{
|
||||
getAll: (params) => ActualCoverageAPI.getAll(params),
|
||||
update: (id: string, data: Partial<ActualCoverage>) => ActualCoverageAPI.update(id, data),
|
||||
create: async () => {
|
||||
throw new Error('Delete operation is not supported for Catch-up Calls');
|
||||
},
|
||||
delete: async (_id: string) => {
|
||||
throw new Error('Delete operation is not supported for Catch-up Calls');
|
||||
},
|
||||
}} formFields={[]} /></>
|
||||
);
|
||||
};
|
||||
125
src/pages/tabReports/AttendanceTab.tsx
Normal file
125
src/pages/tabReports/AttendanceTab.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ReportsTab } from '../../components/reports/ReportsTab';
|
||||
import { AttendanceAPI } from '../../services/reports/attendanceApi';
|
||||
import { Attendance } from '@/types/reports/attendance';
|
||||
import { formatDateTime } from '../../utils/formatDateTime';
|
||||
import { Modal } from '../../components/ui';
|
||||
|
||||
export const AttendanceTab: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signatureModal, setSignatureModal] = useState<{ isOpen: boolean; url: string | undefined }>({
|
||||
isOpen: false,
|
||||
url: undefined
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading ...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<><Modal
|
||||
isOpen={signatureModal.isOpen}
|
||||
onClose={() => setSignatureModal({ isOpen: false, url: undefined })}
|
||||
title="Time Logs"
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={signatureModal.url}
|
||||
alt="Time In & Out Photo"
|
||||
className="max-w-full max-h-64 object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; } } />
|
||||
</div>
|
||||
</Modal>
|
||||
<ReportsTab<Attendance, number>
|
||||
title="Attendance Report"
|
||||
idField="attendanceId"
|
||||
showAddButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name..."
|
||||
showCheckbox={false}
|
||||
showActions={false}
|
||||
showPeriodSelector={true}
|
||||
onPeriodChange={(period: any, startDate: any, endDate: any) => {
|
||||
console.log('Period changed:', { period, startDate, endDate });
|
||||
} }
|
||||
columns={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MRName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'timeIn',
|
||||
label: 'TimeIn',
|
||||
searchable: true,
|
||||
render: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
key: 'timeOut',
|
||||
label: 'TimeOut',
|
||||
searchable: false,
|
||||
render: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
key: 'timeInLocation',
|
||||
label: 'Time_In_Loc',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'timeOutLocation',
|
||||
label: 'Time_Out_Loc',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'signatureFileNameIn',
|
||||
label: 'in_photo',
|
||||
render: (value) => value ? (
|
||||
<button
|
||||
onClick={() => setSignatureModal({ isOpen: true, url: AttendanceAPI.getFileUrl(value) })}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
View Time In Photo
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">No Time In Photo</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'signatureFileNameOut',
|
||||
label: 'out_photo',
|
||||
render: (value) => value ? (
|
||||
<button
|
||||
onClick={() => setSignatureModal({ isOpen: true, url: AttendanceAPI.getFileUrl(value) })}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
View Time Out Photo
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">No Time Out Photo</span>
|
||||
)
|
||||
},
|
||||
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MedRepName Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
api={AttendanceAPI} /></>
|
||||
);
|
||||
};
|
||||
86
src/pages/tabReports/CallRateTab.tsx
Normal file
86
src/pages/tabReports/CallRateTab.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ReportsTab } from '../../components/reports/ReportsTab';
|
||||
import { CallRateAPI } from '../../services/reports/callRateApi';
|
||||
import { CallRate } from '../../types/reports/reports';
|
||||
|
||||
export const CallRateTab: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportsTab<CallRate, number>
|
||||
title="Call Rate Report"
|
||||
idField="mrUserId"
|
||||
showAddButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name..."
|
||||
showCheckbox={false}
|
||||
showActions={false}
|
||||
showPeriodSelector={true} // ✅ Enable the new period selector
|
||||
onPeriodChange={(period: any, startDate: any, endDate: any) => {
|
||||
console.log('Period changed:', { period, startDate, endDate });
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MR Name',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Company',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'totalTargetCall',
|
||||
label: 'Target Call',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'totalActualCallRate',
|
||||
label: 'Actual Call Rate',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'totalMissedCall',
|
||||
label: 'Missed Call',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'totalReschedule',
|
||||
label: 'Reschedule',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'totalCallRatePercentage',
|
||||
label: 'Call Rate (%)',
|
||||
searchable: false,
|
||||
render: (value: number) => `${value.toFixed(2)}%`,
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MedRep Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
api={CallRateAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
76
src/pages/tabReports/CallReachTab.tsx
Normal file
76
src/pages/tabReports/CallReachTab.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ReportsTab } from '../../components/reports/ReportsTab';
|
||||
import { CallReach } from '../../types/reports/reports';
|
||||
import { CallReachAPI } from '../../services/reports/callReachApi';
|
||||
|
||||
export const CallReachTab: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportsTab<CallReach, number>
|
||||
title="Call Reach Report"
|
||||
idField="mrUserId"
|
||||
showAddButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name..."
|
||||
showCheckbox={false}
|
||||
showActions={false}
|
||||
showPeriodSelector={true} // ✅ Enable the new period selector
|
||||
onPeriodChange={(period: any, startDate: any, endDate: any) => {
|
||||
console.log('Period changed:', { period, startDate, endDate });
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MRName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Company',
|
||||
searchable: true ,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'totalTargetCall',
|
||||
label: 'Target_Call_Reach',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'totalActualCallReach',
|
||||
label: 'Actual_Call_Reach',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'totalCallReachPercentage',
|
||||
label: 'Call_Reach (%)',
|
||||
searchable: false,
|
||||
render: (value: number) => `${value}%`,
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MedRepName Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
api={CallReachAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
119
src/pages/tabReports/CatchUpCallTab.tsx
Normal file
119
src/pages/tabReports/CatchUpCallTab.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ReportsTab } from '../../components/reports/ReportsTab';
|
||||
import { CatchUpCall } from '../../types/reports/reports';
|
||||
import { CatchUpCallAPI } from '../../services/reports/catchUpCallApi';
|
||||
import { formatDateForCSV } from '../../utils/csvExport';
|
||||
|
||||
export const CatchUpCallTab: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportsTab<CatchUpCall, string>
|
||||
title="Catch-up Call"
|
||||
idField="catchUpCallId"
|
||||
showAddButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name..."
|
||||
showCheckbox={false}
|
||||
showActions={true}
|
||||
showPeriodSelector={true} // ✅ Enable the new period selector
|
||||
onPeriodChange={(period: any, startDate: any, endDate: any) => {
|
||||
console.log('Period changed:', { period, startDate, endDate });
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MRName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'doctorName',
|
||||
label: 'DoctorName',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Company',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'originalCallDate',
|
||||
label: 'Original_date',
|
||||
searchable: false,
|
||||
render: (value) => formatDateForCSV(value)
|
||||
},
|
||||
{
|
||||
key: 'catchUpCallDate',
|
||||
label: 'Catch-up_date',
|
||||
searchable: false,
|
||||
render: (value) => formatDateForCSV(value)
|
||||
},
|
||||
{
|
||||
key: 'startTime',
|
||||
label: 'Start_Time',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'endTime',
|
||||
label: 'End_Time',
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: 'missedReason',
|
||||
label: 'reason',
|
||||
searchable: false,
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MedRep Name',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'doctorName',
|
||||
label: 'Doctor Name',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'missedReason',
|
||||
label: 'Catch-up Call Reason',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
api={{
|
||||
getAll: (params) => CatchUpCallAPI.getAll(params),
|
||||
update: (id: string, data: Partial<CatchUpCall>) => CatchUpCallAPI.update(id, data),
|
||||
create: async (data: Partial<CatchUpCall>) => {
|
||||
// Uses the createOrUpdate method which handles both create and update
|
||||
return CatchUpCallAPI.createOrUpdate(data);
|
||||
},
|
||||
delete: async (_id: string) => {
|
||||
// Delete is not supported for Catch-up Calls
|
||||
throw new Error('Delete operation is not supported for Catch-up Calls');
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
93
src/pages/tabReports/ReportsPage.tsx
Normal file
93
src/pages/tabReports/ReportsPage.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserAccountTab } from './../tabs/UserAccountTab';
|
||||
import { ApiHelpers } from '../../utils/apiHelpers';
|
||||
import { CallRateTab } from './CallRateTab';
|
||||
import { AttendanceTab } from './AttendanceTab';
|
||||
import { CallReachTab } from './CallReachTab';
|
||||
import { CatchUpCallTab } from './CatchUpCallTab';
|
||||
import { ActualCoverageTab } from './ActualCoverageTab';
|
||||
|
||||
interface ReportsPageProps {
|
||||
user: any;
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| 'attendance'
|
||||
| 'callReach'
|
||||
| 'callRate'
|
||||
| 'catchUpCall'
|
||||
| 'actualCoverage'
|
||||
| 'missedApproval';
|
||||
|
||||
export const ReportsPage: React.FC<ReportsPageProps> = () => {
|
||||
const userRole = (ApiHelpers.getUserRole() || 'MEDREP').toUpperCase();
|
||||
|
||||
const allTabs = [
|
||||
{ id: 'attendance' as TabType, label: 'Attendance', icon: '📝' },
|
||||
{ id: 'callReach' as TabType, label: 'CallReach', icon: '📞' },
|
||||
{ id: 'callRate' as TabType, label: 'CallRate', icon: '📊' },
|
||||
{ id: 'catchUpCall' as TabType, label: 'CatchUpCall', icon: '⏰' },
|
||||
{ id: 'actualCoverage' as TabType, label: 'ActualCoverage', icon: '📶' },
|
||||
];
|
||||
|
||||
const visibleTabs = allTabs.filter(
|
||||
(tab) =>
|
||||
tab.id !== 'missedApproval' ||
|
||||
['ADMIN', 'NSM', 'DSM'].includes(userRole)
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>(
|
||||
visibleTabs[0]?.id || 'callReach'
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'missedApproval':
|
||||
if (['ADMIN', 'NSM', 'DSM'].includes(userRole)) {
|
||||
return <UserAccountTab />;
|
||||
}
|
||||
return null;
|
||||
case 'attendance':
|
||||
return <AttendanceTab />;
|
||||
case 'callReach':
|
||||
return <CallReachTab />;
|
||||
case 'callRate':
|
||||
return <CallRateTab />;
|
||||
case 'catchUpCall':
|
||||
return <CatchUpCallTab />;
|
||||
case 'actualCoverage':
|
||||
return <ActualCoverageTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex overflow-x-auto">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center px-6 py-4 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">{renderTabContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
src/pages/tabs/AreaTab.tsx
Normal file
48
src/pages/tabs/AreaTab.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { Areas } from '../../types/doctor/generic';
|
||||
import { AreasAPI } from '../../services/generic/areasApi';
|
||||
|
||||
export const AreaTab: React.FC = () => {
|
||||
return (
|
||||
<GenericMaintenanceTab<Areas,number>
|
||||
title="Areas"
|
||||
idField="areaId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'areaId', label: 'ID' , searchable: false },
|
||||
{ key: 'areaName', label: 'Area Name' , searchable: true},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'areaName',
|
||||
label: 'Area Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter area name',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={AreasAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
56
src/pages/tabs/DepartmentTab.tsx
Normal file
56
src/pages/tabs/DepartmentTab.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { Department } from '../../types/doctor/generic';
|
||||
import { DepartmentAPI } from '../../services/generic/departmentApi';
|
||||
|
||||
export const DepartmentTab: React.FC = () => {
|
||||
return (
|
||||
<GenericMaintenanceTab<Department,number>
|
||||
title="Departments"
|
||||
idField="departmentId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'departmentId', label: 'ID' , searchable: false },
|
||||
{ key: 'departmentCode', label: 'Department Code' , searchable: true},
|
||||
{ key: 'departmentName', label: 'Department Name' , searchable: true},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'departmentCode',
|
||||
label: 'Department Code',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter department code',
|
||||
},
|
||||
{
|
||||
key: 'departmentName',
|
||||
label: 'Department Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter department name',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={DepartmentAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
98
src/pages/tabs/DistrictTab.tsx
Normal file
98
src/pages/tabs/DistrictTab.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { DistrictAPI } from '../../services/generic/districtApi';
|
||||
import { District } from '../../types/doctor/generic';
|
||||
import { useAreasApi } from '../../hooks/generic/useArea';
|
||||
|
||||
export const DistrictTab: React.FC = () => {
|
||||
const { areas, fetchAreas } = useAreasApi();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchAreas()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const areaOptions = areas
|
||||
.filter(a => a.isActive !== false)
|
||||
.map(a => ({
|
||||
value: a.areaId,
|
||||
label: a.areaName ?? '(No Name)'
|
||||
}));
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
return (
|
||||
<GenericMaintenanceTab<District, number>
|
||||
title="Districts"
|
||||
idField="districtId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'districtId', label: 'ID' , searchable: false},
|
||||
{ key: 'districtName', label: 'District Name', searchable: true},
|
||||
{
|
||||
key: 'areaId',
|
||||
label: 'Area Name',
|
||||
searchable: true,
|
||||
filterable: true, // Enable filtering by territory
|
||||
filterOptions: areaOptions, // Use predefined territory options
|
||||
getFilterValue: (row) => row.areas?.areaId, // Get the actual ID for filtering
|
||||
render: (_value, row) => row.areas?.areaName ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
searchable: false,
|
||||
filterable: true, // Enable filtering by status
|
||||
filterOptions: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' }
|
||||
],
|
||||
getFilterValue: (row) => String(row.isActive),
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'districtName',
|
||||
label: 'District Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter district name',
|
||||
},
|
||||
{
|
||||
key: 'areaId',
|
||||
label: 'Area',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: 'Select Area',
|
||||
options: areaOptions
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={DistrictAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
66
src/pages/tabs/DivisionTab.tsx
Normal file
66
src/pages/tabs/DivisionTab.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { DivisionAPI } from '../../services/generic/divisionApi';
|
||||
import { Division } from '../../types/doctor/generic';
|
||||
import { useDivisionApi } from '../../hooks/generic/useDivision';
|
||||
|
||||
export const DivisionTab: React.FC = () => {
|
||||
const { fetchDivisions } = useDivisionApi();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchDivisions()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
return (
|
||||
<GenericMaintenanceTab<Division,number>
|
||||
title="Divisions"
|
||||
idField="divisionId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'divisionId', label: 'ID' , searchable: false},
|
||||
{ key: 'divisionName', label: 'Division Name', searchable: true},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'divisionName',
|
||||
label: 'Division Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter division name',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={DivisionAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
77
src/pages/tabs/InstitutionCategoryTab.tsx
Normal file
77
src/pages/tabs/InstitutionCategoryTab.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { InstitutionCategory } from '../../types/doctor/generic';
|
||||
import { InstitutionCategoryAPI } from '../../services/generic/institutionCategoryApi';
|
||||
import { useInstitutionCategoryApi } from '../../hooks/generic/useInstitutionCategory';
|
||||
|
||||
export const InstitutionCategoryTab: React.FC = () => {
|
||||
const { fetchInstitutionCategory } = useInstitutionCategoryApi();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchInstitutionCategory()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<GenericMaintenanceTab<InstitutionCategory,number>
|
||||
title="Categories"
|
||||
idField="institutionCategoryId"
|
||||
showActions={true}
|
||||
showEditButton={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'institutionCategoryId', label: 'ID' ,searchable: false},
|
||||
{ key: 'institutionCategoryCode', label: 'Category Code' },
|
||||
{ key: 'institutionCategoryName', label: 'Category Name' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'institutionCategoryCode',
|
||||
label: 'Category Code',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter category code',
|
||||
},
|
||||
{
|
||||
key: 'institutionCategoryName',
|
||||
label: 'Category Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter category name',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={InstitutionCategoryAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
140
src/pages/tabs/InstitutionTab.tsx
Normal file
140
src/pages/tabs/InstitutionTab.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { InstitutionAPI } from '../../services/generic/institutionApi';
|
||||
import { Institution } from '../../types/doctor/generic';
|
||||
import { useInstitutionCategoryApi } from '../../hooks/generic/useInstitutionCategory';
|
||||
import { useTerritoryApi } from '../../hooks/generic/useTerritoryApi';
|
||||
|
||||
export const InstitutionTab: React.FC = () => {
|
||||
const { institutionCategory, fetchInstitutionCategory } = useInstitutionCategoryApi();
|
||||
const { territory, fetchTerritory } = useTerritoryApi();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load dropdown data on component mount
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchInstitutionCategory(),
|
||||
fetchTerritory()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Convert to dropdown options format
|
||||
const territoryOptions = territory
|
||||
.filter(t => t.isActive !== false)
|
||||
.map(t => ({
|
||||
value: t.territoryId,
|
||||
label: t.territoryName
|
||||
}));
|
||||
|
||||
const categoryOptions = institutionCategory
|
||||
.filter(c => c.isActive !== false)
|
||||
.map(c => ({
|
||||
value: c.institutionCategoryId,
|
||||
label: `${c.institutionCategoryCode} - ${c.institutionCategoryName}`
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-4">Loading options...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericMaintenanceTab<Institution, number>
|
||||
title="Institutions"
|
||||
idField="institutionId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
enableSearch={true}
|
||||
searchPlaceholder="Search by name, territory, or category..."
|
||||
columns={[
|
||||
{
|
||||
key: 'institutionId',
|
||||
label: 'ID',
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
key: 'institutionName',
|
||||
label: 'Institution Name',
|
||||
searchable: true
|
||||
},
|
||||
{
|
||||
key: 'territoryId',
|
||||
label: 'Territory Name',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
filterOptions: territoryOptions, // Use predefined territory options
|
||||
getFilterValue: (row) => row.territories?.territoryId, // Get the actual ID for filtering
|
||||
render: (_value, row) => row.territories?.territoryName ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'institutionCategoryId',
|
||||
label: 'Code - Category',
|
||||
searchable: true,
|
||||
filterable: true, // Enable filtering by category
|
||||
filterOptions: categoryOptions, // Use predefined category options
|
||||
getFilterValue: (row) => row.institutionCategory?.institutionCategoryId,
|
||||
render: (_value, row) =>
|
||||
row.institutionCategory
|
||||
? `${row.institutionCategory.institutionCategoryCode} - ${row.institutionCategory.institutionCategoryName}`
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
searchable: false,
|
||||
filterable: true, // Enable filtering by status
|
||||
filterOptions: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' }
|
||||
],
|
||||
getFilterValue: (row) => String(row.isActive),
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'institutionName',
|
||||
label: 'Institution Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter institution name',
|
||||
},
|
||||
{
|
||||
key: 'territoryId',
|
||||
label: 'Territory',
|
||||
type: 'select',
|
||||
required: false,
|
||||
placeholder: 'Select territory',
|
||||
options: territoryOptions
|
||||
},
|
||||
{
|
||||
key: 'institutionCategoryId',
|
||||
label: 'Code - Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
placeholder: 'Select category',
|
||||
options: categoryOptions
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={InstitutionAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
292
src/pages/tabs/InventoryTab.tsx
Normal file
292
src/pages/tabs/InventoryTab.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { Inventory } from '../../types/doctor/generic';
|
||||
import { InventoryAPI, ExcelUploadResponse } from '../../services/generic/inventoryApi';
|
||||
import { Button, LoadingSpinner, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useInventoryApi } from '../../hooks/generic/useInventory';
|
||||
|
||||
export const InventoryTab: React.FC = () => {
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const { fetchInventory } = useInventoryApi();
|
||||
const [, setRefreshKey] = useState(0);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<ExcelUploadResponse | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls)$/i)) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Invalid File',
|
||||
message: 'Please select a valid Excel file (.xlsx or .xls)'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (20MB)
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'File Too Large',
|
||||
message: 'File size must not exceed 20MB'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadFile(file);
|
||||
setUploadResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadExcel = async () => {
|
||||
if (!uploadFile) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'No File',
|
||||
message: 'Please select a file to upload'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const result = await InventoryAPI.uploadExcel(uploadFile);
|
||||
|
||||
setUploadResult(result);
|
||||
if (result.success) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Upload Successful',
|
||||
message: `${result.processedRows} inventory records imported successfully`
|
||||
});
|
||||
|
||||
// Refresh the table after successful upload
|
||||
await fetchInventory(); // Fetch fresh data
|
||||
setRefreshKey(prev => prev + 1); // Trigger re-render of GenericMaintenanceTab
|
||||
} else {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Upload Failed',
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Upload Error',
|
||||
message: error.message || 'Failed to upload file'
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseUploadModal = () => {
|
||||
setIsUploadModalOpen(false);
|
||||
setUploadFile(null);
|
||||
setUploadResult(null);
|
||||
};
|
||||
|
||||
if (isUploading) {
|
||||
return <LoadingSpinner message="Loading please wait..." />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<GenericMaintenanceTab<Inventory, string>
|
||||
title="Inventory"
|
||||
idField="inventoryId"
|
||||
showCheckbox={true}
|
||||
showActions={true}
|
||||
showEditButton={false}
|
||||
showAddButton={false}
|
||||
customHeaderActions={
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsUploadModalOpen(true)}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Upload Excel Template
|
||||
</Button>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Brand_Name',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
getFilterValue: (row) => row.description,
|
||||
render: (_value, row) => row.description,
|
||||
},
|
||||
{ key: 'qtyIn', label: 'Qty_In', searchable: false },
|
||||
{ key: 'qtyOut', label: 'Qty_Out', searchable: false },
|
||||
{ key: 'cycleMonth',
|
||||
label: 'Month',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
getFilterValue: (row) => row.cycleMonth, // ensures filtering uses raw value
|
||||
filterOptions: Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
][i]
|
||||
})),
|
||||
render: (value) => {
|
||||
const monthNames = [
|
||||
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return monthNames[value] || value;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'cycleYear',
|
||||
label: 'Year',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
getFilterValue: (row) => row.cycleYear,
|
||||
render: (_value, row) => row.cycleYear,
|
||||
},
|
||||
{
|
||||
key: 'medRepName',
|
||||
label: 'MedRep_Name',
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
getFilterValue: (row) => row.medRepName,
|
||||
render: (_value, row) => row.medRepName,
|
||||
},
|
||||
]}
|
||||
formFields={[]}
|
||||
api={InventoryAPI}
|
||||
/>
|
||||
|
||||
{/* Excel Upload Modal */}
|
||||
<Modal
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={handleCloseUploadModal}
|
||||
title="Upload Inventory Excel Template"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 mb-2">Template Instructions:</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||
<li>Use the provided Excel template with fixed headers</li>
|
||||
<li>Headers: BRAND, January-December, Year, Medrep_Name</li>
|
||||
<li>Only rows with quantities greater than 0 will be imported</li>
|
||||
<li>Each month with a non-zero value creates a separate inventory record</li>
|
||||
<li>File size limit: 20MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Excel File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
className="block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{uploadFile && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Selected: {uploadFile.name} ({(uploadFile.size / 1024).toFixed(2)} KB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Result */}
|
||||
{uploadResult && (
|
||||
<div className={`border rounded-lg p-4 ${uploadResult.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<h4 className={`font-medium mb-2 ${uploadResult.success ? 'text-green-900' : 'text-red-900'}`}>
|
||||
{uploadResult.success ? 'Upload Successful' : 'Upload Failed'}
|
||||
</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>Total Rows: {uploadResult.totalRows}</p>
|
||||
<p>Processed: {uploadResult.processedRows}</p>
|
||||
<p>Skipped: {uploadResult.skippedRows}</p>
|
||||
</div>
|
||||
|
||||
{uploadResult.errors.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm font-medium text-red-900 mb-1">Errors:</p>
|
||||
<ul className="text-sm text-red-800 space-y-1 max-h-40 overflow-y-auto">
|
||||
{uploadResult.errors.map((error, idx) => (
|
||||
<li key={idx}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadResult.insertedItems.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm font-medium text-green-900 mb-1">Imported Brands:</p>
|
||||
<ul className="text-sm text-green-800 space-y-1 max-h-40 overflow-y-auto">
|
||||
{uploadResult.insertedItems.map((item, idx) => (
|
||||
<li key={idx}>• {item.brand} - {item.totalQty} units ({item.medRepName})</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCloseUploadModal}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{uploadResult?.success ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!uploadResult?.success && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUploadExcel}
|
||||
disabled={!uploadFile || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Uploading...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Upload Excel
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
80
src/pages/tabs/ProductTab.tsx
Normal file
80
src/pages/tabs/ProductTab.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { GenericMaintenanceTab } from '../../components/generic/GenericMaintenanceTab';
|
||||
import { Product } from '../../types/doctor/generic';
|
||||
import { ProductAPI } from '../../services/generic/productApi';
|
||||
|
||||
export const ProductTab: React.FC = () => {
|
||||
return (
|
||||
<GenericMaintenanceTab<Product, number>
|
||||
title="Products"
|
||||
idField="productId"
|
||||
showActions={true}
|
||||
showDeleteButton={false}
|
||||
columns={[
|
||||
{ key: 'productId', label: 'ID', searchable: false },
|
||||
{ key: 'productName', label: 'Product Name', searchable: true },
|
||||
{
|
||||
key: 'productLink',
|
||||
label: 'File',
|
||||
render: (value) => value ? (
|
||||
<a
|
||||
href={ProductAPI.getFileUrl(value)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
View File
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">No file</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<span className={value ? 'text-green-600' : 'text-gray-400'}>
|
||||
{value ? '● Active' : '○ Inactive'}
|
||||
</span>
|
||||
),
|
||||
filterable: true,
|
||||
filterOptions: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' }
|
||||
],
|
||||
getFilterValue: (item) => String(item.isActive)
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
key: 'productName',
|
||||
label: 'Product Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter product name',
|
||||
minLength: 3,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
key: 'productLink',
|
||||
label: 'Product File',
|
||||
type: 'file',
|
||||
required: false,
|
||||
accept: 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*',
|
||||
maxFileSize: 20 * 1024 * 1024, // 20MB
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
api={ProductAPI}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user