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

This commit is contained in:
rowell_m_soriano 2026-07-30 13:56:40 +08:00
commit f568a8aa14
151 changed files with 23007 additions and 0 deletions

17
.eslintrc.json Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

20
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 273 KiB

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
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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
View 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
View 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
View 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
View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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>
);
};

View 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>
</>
);
};

View 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>
)}
</>
);
};

View File

@ -0,0 +1,3 @@
export * from './AppointmentCard';
export * from './AppointmentForm';
export * from './MonthPlanCopy';

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,2 @@
export * from './ComingSoon';
export * from './LoadingSpinner';

View 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>
);
};

View File

@ -0,0 +1,2 @@
export * from './DashboardOverview';
export * from '../../pages/SettingsPage';

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,3 @@
export * from './DoctorForm';
export * from './DoctorCard';
export * from './DoctorDetailView';

View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View File

@ -0,0 +1,3 @@
export * from './Sidebar';
export * from './Header';
export * from './Layout';

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View File

View 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;

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View File

@ -0,0 +1 @@
export * from './navigation';

View 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' },
];

View 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>
);
};

View 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
View File

@ -0,0 +1 @@
export * from './ToastContext';

30
src/dto/planningDtos.ts Normal file
View 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;
}

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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 };
};

View 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
View File

@ -0,0 +1 @@
export * from './useApi';

View 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
View 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 };
};

View 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
View 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;
};

View 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
View 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
View 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
View 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
View 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
View 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>
);
};

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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} />;
};

View 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';

View 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={[]} /></>
);
};

View 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} /></>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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');
},
}}
/>
);
};

View 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>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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>
</>
);
};

View 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