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