Compare commits

..

No commits in common. "5b728a8414aa73f0459a6b96fe013ea46f6ea261" and "592c0e24f97bc80abd484e0c1eb265bf92d7930f" have entirely different histories.

115 changed files with 832 additions and 9875 deletions

View File

@ -1,27 +0,0 @@
# name: Playwright Tests
# on:
# push:
# branches: [ main, master ]
# pull_request:
# branches: [ main, master ]
# jobs:
# test:
# timeout-minutes: 60
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: lts/*
# - name: Install dependencies
# run: npm ci
# - name: Install Playwright Browsers
# run: npx playwright install --with-deps
# - name: Run Playwright tests
# run: npx playwright test
# - uses: actions/upload-artifact@v4
# if: ${{ !cancelled() }}
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 30

6
.gitignore vendored
View File

@ -88,9 +88,3 @@ test-results/
# Misc
# =====================
*.tgz
# Playwright
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

View File

@ -1,2 +0,0 @@
Node Version - v22.19.0
Npm Version - 10.9.3

View File

@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
services:
hrms-frontend:

View File

@ -4,7 +4,7 @@ server {
location / {
root /usr/share/nginx/html;
index index.html index.html;
index index.html index.htm;
# This is crucial for React Router to work on page refresh
try_files $uri $uri/ /index.html;
}

1583
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/x-charts": "^9.8.0",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.23.0",
"protobufjs": "^8.6.5",
@ -25,12 +21,10 @@
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.1",
"react-spinners": "^0.17.0",
"recharts": "^3.9.2",
"sonner": "^2.0.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",

View File

@ -1,79 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -0,0 +1,6 @@
[
{ "date": "2026-07-01", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-02", "timeIn": "09:00 AM", "timeOut": "06:00 PM", "loggedHours": "9h 00m", "status": "Present" },
{ "date": "2026-07-07", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Holiday" },
{ "date": "2026-07-15", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Absent" }
]

View File

@ -0,0 +1,22 @@
[
{ "date": "2026-07-01", "timeIn": "09:05 AM", "timeOut": "06:15 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-02", "timeIn": "10:30 AM", "timeOut": "06:30 PM", "loggedHours": "8h 00m", "status": "Late" },
{ "date": "2026-07-03", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Leave" },
{ "date": "2026-07-04", "timeIn": "10:12 AM", "timeOut": "06:30 PM", "loggedHours": "8h 18m", "status": "Late" },
{ "date": "2026-07-06", "timeIn": "09:00 AM", "timeOut": "05:05 PM", "loggedHours": "8h 05m", "status": "Present" },
{ "date": "2026-07-07", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Holiday" },
{ "date": "2026-07-08", "timeIn": "09:15 AM", "timeOut": "02:00 PM", "loggedHours": "4h 45m", "status": "Half Day" },
{ "date": "2026-07-10", "timeIn": "08:45 AM", "timeOut": "05:30 PM", "loggedHours": "8h 45m", "status": "WFH" },
{ "date": "2026-07-13", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-15", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Absent" },
{ "date": "2026-07-17", "timeIn": "09:15 AM", "timeOut": "-", "loggedHours": "-", "status": "Mispunch" },
{ "date": "2026-07-20", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Holiday" },
{ "date": "2026-07-21", "timeIn": "09:00 AM", "timeOut": "06:00 PM", "loggedHours": "9h 00m", "status": "Present" },
{ "date": "2026-07-26", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Holiday" },
{ "date": "2026-07-27", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "WFH" },
{ "date": "2026-07-28", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-29","timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-30", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-31", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m","status": "Present" },
{ "date": "2026-08-01", "timeIn": "08:50 AM", "timeOut": "06:00 PM", "loggedHours": "9h 10m", "status": "Present" }
]

View File

@ -0,0 +1,7 @@
[
{ "date": "2026-07-01", "timeIn": "09:05 AM", "timeOut": "06:15 PM", "loggedHours": "9h 10m", "status": "Present" },
{ "date": "2026-07-02", "timeIn": "10:30 AM", "timeOut": "06:30 PM", "loggedHours": "8h 00m", "status": "Late" },
{ "date": "2026-07-03", "timeIn": "-", "timeOut": "-", "loggedHours": "-", "status": "Leave" },
{ "date": "2026-07-04", "timeIn": "09:00 AM", "timeOut": "05:05 PM", "loggedHours": "8h 05m", "status": "Present" },
{ "date": "2026-07-08", "timeIn": "09:15 AM", "timeOut": "02:00 PM", "loggedHours": "4h 45m", "status": "Half Day" }
]

View File

@ -1,74 +0,0 @@
[
{
"companyId": "CMP001",
"companyName": "CliniLaunch Research Institute - CLRI",
"policies": [
{
"id": "POL001",
"title": "Loss of Pay (LOP)",
"category": "Leave",
"description": "If an employee exhausts all available leave balances and cannot attend work, the days will be marked as LOP. LOP is deducted from the monthly salary on a pro-rata basis."
},
{
"id": "POL002",
"title": "Casual Leave Policy",
"category": "Leave",
"description": "Employees are entitled to 10 casual leaves per calendar year. A maximum of 2 casual leaves can be taken in a month. Leaves must be approved by the reporting manager at least 2 days in advance."
},
{
"id": "POL003",
"title": "Optional Holidays",
"category": "Leave",
"description": "Employees can choose up to 4 optional holidays from the list provided by the HR department. These are not encashable and cannot be carried forward."
},
{
"id": "POL004",
"title": "Late Arrival Rule",
"category": "Attendance",
"description": "Employees are expected to check in by 10:00 AM. A grace period of 10 minutes is allowed. More than 2 late arrivals in a month will result in a half-day leave deduction."
},
{
"id": "POL005",
"title": "Shift Timings",
"category": "Attendance",
"description": "General shift is from 10:00 AM to 06:00 PM. Employees must complete a minimum of 8 hours per day, excluding the lunch break."
}
]
},
{
"companyId": "CMP002",
"companyName": "Win in Life Academy - WILA",
"policies": [
{
"id": "POL001",
"title": "Loss of Pay (LOP)",
"category": "Leave",
"description": "If an employee exhausts all available leave balances and cannot attend work, the days will be marked as LOP. LOP is deducted from the monthly salary on a pro-rata basis."
},
{
"id": "POL002",
"title": "Casual Leave Policy",
"category": "Leave",
"description": "Employees are entitled to 10 casual leaves per calendar year. A maximum of 2 casual leaves can be taken in a month. Leaves must be approved by the reporting manager at least 2 days in advance."
},
{
"id": "POL003",
"title": "Optional Holidays",
"category": "Leave",
"description": "Employees can choose up to 4 optional holidays from the list provided by the HR department. These are not encashable and cannot be carried forward."
},
{
"id": "POL004",
"title": "Late Arrival Rule",
"category": "Attendance",
"description": "Employees are expected to check in by 10:00 AM. A grace period of 10 minutes is allowed. More than 2 late arrivals in a month will result in a half-day leave deduction."
},
{
"id": "POL005",
"title": "Shift Timings",
"category": "Attendance",
"description": "General shift is from 10:00 AM to 06:00 PM. Employees must complete a minimum of 8 hours per day, excluding the lunch break."
}
]
}
]

View File

@ -0,0 +1,5 @@
[
{ "date": "2026-07-01", "timeIn": "09:30 AM", "timeOut": "06:30 PM", "loggedHours": "9h 00m", "status": "Late" },
{ "date": "2026-07-03", "timeIn": "08:45 AM", "timeOut": "05:30 PM", "loggedHours": "8h 45m", "status": "WFH" },
{ "date": "2026-07-10", "timeIn": "09:00 AM", "timeOut": "-", "loggedHours": "-", "status": "Mispunch" }
]

View File

@ -1,43 +1,10 @@
import axios from 'axios';
import { store } from '../store/store';
// Use the Vite Proxy! Do not put the full URL here.
// By using an empty string, requests go to localhost:5173/api/...
// and Vite forwards them to 192.168.60.237:8000/api/...
const API_BASE_URL = '';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use(
(config) => {
const state = store.getState();
const { mockUserId, mockUserRole, mockBranches } = state.role;
if (mockUserId) {
config.headers['X-Mock-User-Id'] = mockUserId;
config.headers['X-Mock-User-Role'] = mockUserRole;
config.headers['X-Mock-Branches'] = mockBranches;
}
return config;
},
(error) => Promise.reject(error)
);
// Simulates network latency and API fetching from public/mockApi/
export const fetchMockData = async <T>(endpoint: string, delay = 500): Promise<T> => {
await new Promise((resolve) => setTimeout(resolve, delay));
const response = await fetch(`/mockApi/${endpoint}`);
if (!response.ok) throw new Error(`API Error: Failed to fetch ${endpoint}`);
const response = await fetch(`/mockApi/${endpoint}.json`);
if (!response.ok) {
throw new Error(`API Error: Failed to fetch ${endpoint}`);
}
return response.json() as Promise<T>;
};
export const postMockData = async <T>(endpoint: string, payload: T, delay = 800): Promise<T> => {
await new Promise((resolve) => setTimeout(resolve, delay));
console.log(`[Mock POST] /mockApi/${endpoint}`, payload);
return payload;
};

View File

@ -1,119 +0,0 @@
import { useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import type { RootState } from '../../store/store';
import { getSidebarItems, type NavItem } from './types/sidebarConfig';
import { ChevronRight, Home } from 'lucide-react';
// Fallback map for URL segments if dynamic lookup fails (e.g., for IDs in URL)
const routeNameMap: Record<string, string> = {
dashboard: 'Dashboard',
attendance: 'Attendance Summary',
leave: 'Leave Summary',
approval: 'Approve Leaves',
'attendance-summary': 'Attendance Summary',
monthly: 'Monthly Report',
daily: 'Daily Report',
regularization: 'Regularization',
'leave-summary': 'Leave Summary',
'master-data': 'Master Data Service',
company: 'Manage Company',
branches: 'Manage Branches',
departments: 'Manage Departments',
'job-roles': 'Manage Job Roles',
employees: 'Manage Employees',
policies: 'Policy and Rules',
applications: 'Leave Applications',
balance: 'Leave Balance Report'
};
export default function Breadcrumbs() {
const location = useLocation();
const currentRole = useSelector((state: RootState) => state.role.currentRole);
const pathnames = location.pathname.split('/').filter(x => x);
// Generate a map of paths to breadcrumb trails based on the user's role sidebar config
const dynamicBreadcrumbs = useMemo(() => {
const items = getSidebarItems(currentRole);
const map: Record<string, { name: string; path?: string }[]> = {};
const traverse = (nodes: NavItem[], trail: { name: string; path?: string }[] = []) => {
for (const node of nodes) {
const newTrail = [...trail, { name: node.name, path: node.path }];
if (node.path) {
map[node.path] = newTrail;
}
if (node.children && node.children.length > 0) {
traverse(node.children, newTrail);
}
}
};
traverse(items);
return map;
}, [currentRole]);
// Special case for the main dashboard route
if (pathnames.length === 0 || (pathnames.length === 1 && pathnames[0] === 'dashboard')) {
return (
<div className="flex items-center text-sm text-text-muted mb-4">
<Home size={16} className="mr-2 text-text-light" />
<span className="font-medium text-text-primary">Dashboard</span>
</div>
);
}
// Look up the current URL in our dynamically generated breadcrumb map
const breadcrumbTrail = dynamicBreadcrumbs[location.pathname];
// If found, render the exact hierarchy from the sidebar config
if (breadcrumbTrail) {
return (
<div className="flex items-center text-sm text-text-muted mb-4">
<Link to="/dashboard" className="hover:text-primary flex items-center">
<Home size={16} className="mr-1 text-text-light" />
</Link>
{breadcrumbTrail.map((crumb, index) => {
const isLast = index === breadcrumbTrail.length - 1;
return (
<div key={index} className="flex items-center">
<ChevronRight size={16} className="mx-1 text-text-light" />
{isLast || !crumb.path ? (
<span className={`font-medium ${isLast ? 'text-text-primary' : 'text-text-muted'}`}>
{crumb.name}
</span>
) : (
<Link to={crumb.path} className="hover:text-primary">{crumb.name}</Link>
)}
</div>
);
})}
</div>
);
}
// Fallback to URL segment mapping for unknown routes (like viewing a specific employee by ID)
return (
<div className="flex items-center text-sm text-text-muted mb-4">
<Link to="/dashboard" className="hover:text-primary flex items-center">
<Home size={16} className="mr-1 text-text-light" />
</Link>
{pathnames.map((name, index) => {
const routeTo = `/${pathnames.slice(0, index + 1).join('/')}`;
const isLast = index === pathnames.length - 1;
const displayName = routeNameMap[name] || name.charAt(0).toUpperCase() + name.slice(1);
return (
<div key={routeTo} className="flex items-center">
<ChevronRight size={16} className="mx-1 text-text-light" />
{isLast ? (
<span className="font-medium text-text-primary">{displayName}</span>
) : (
<Link to={routeTo} className="hover:text-primary">{displayName}</Link>
)}
</div>
);
})}
</div>
);
}

View File

@ -3,43 +3,48 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useIsFetching } from '@tanstack/react-query';
import Sidebar from './Sidebar';
import Navbar from './Navbar';
import Breadcrumbs from './Breadcrumbs';
import DashboardRouter from '../../features/dashboard/routes/DashboardRouter';
import AttendanceRouter from '../../features/attendance/routes/AttendanceRouter';
import LeaveRouter from '../../features/leave/routes/LeaveRouter';
import ApprovalRouter from '../../features/leave/routes/ApprovalRouter';
import AttendanceSummaryRouter from '../../features/attendance/routes/AttendanceSummaryRouter';
import LeaveSummaryRouter from '../../features/leave/routes/LeaveSummaryRouter';
import MasterDataRouter from '../../features/masterData/routes/MasterDataRouter';
import PolicyRouter from '../../features/masterData/routes/PolicyRouter';
import ManageEmployeesRouter from '../../features/dashboard/routes/ManageEmployeesRouter';
import ManageAdminsRouter from '../../features/dashboard/routes/ManageAdminsRouter';
import PolicyRouter from '../../features/dashboard/routes/PolicyRouter';
export default function MainLayout() {
const [isCollapsed, setIsCollapsed] = useState(false);
// Returns the number of queries currently fetching data globally
const isFetching = useIsFetching();
return (
<div className="flex h-screen bg-app-bg overflow-hidden">
<Sidebar isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} />
{/* Added 'relative' and 'overflow-hidden' here to contain the loading bar */}
<div className="flex-1 flex flex-col relative overflow-hidden">
<Navbar />
{/* Global Top Loading Bar - now safely constrained to this container */}
{isFetching > 0 && (
<div className="absolute top-16 left-0 right-0 h-1 bg-primary z-50 animate-pulse" />
)}
<main className="flex-1 overflow-y-auto p-6">
<Breadcrumbs />
<Routes>
<Route path="/dashboard" element={<DashboardRouter />} />
<Route path="/attendance" element={<AttendanceRouter />} />
<Route path="/leave" element={<LeaveRouter />} />
<Route path="/approval" element={<ApprovalRouter />} />
<Route path="/attendance-summary/*" element={<AttendanceSummaryRouter />} />
<Route path="/leave-summary/*" element={<LeaveSummaryRouter />} />
<Route path="/master-data/*" element={<MasterDataRouter />} />
<Route path="/attendance-summary" element={<AttendanceSummaryRouter />} />
<Route path="/leave-summary" element={<LeaveSummaryRouter />} />
<Route path="/manage-employees" element={<ManageEmployeesRouter />} />
<Route path="/manage-admins" element={<ManageAdminsRouter />} />
<Route path="/policies" element={<PolicyRouter />} />
{/* Redirect to dashboard if no route matches */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>

View File

@ -2,22 +2,39 @@ import { useDispatch, useSelector } from 'react-redux';
import type { RootState } from '../../store/store';
import { setRole, type UserRole } from '../../store/roleSlice';
import { Search, UserCircle, ChevronDown, X, ArrowRight } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getSidebarItems, type NavItem } from './types/sidebarConfig';
// Helper function to flatten the nested sidebar items into a simple list for search
const flattenNavItems = (items: NavItem[]): { name: string; path: string }[] => {
let result: { name: string; path: string }[] = [];
for (const item of items) {
if (item.path) {
result.push({ name: item.name, path: item.path });
}
if (item.children && item.children.length > 0) {
result = result.concat(flattenNavItems(item.children));
}
}
return result;
const roleSearchData: Record<UserRole, { name: string; path: string }[]> = {
employee: [
{ name: 'My Dashboard', path: '/dashboard' },
{ name: 'Mark Attendance', path: '/attendance' },
{ name: 'Apply for Leave', path: '/leave' },
],
manager: [
{ name: 'Manager Dashboard', path: '/dashboard' },
{ name: 'Team Attendance', path: '/attendance' },
{ name: 'Team Leaves', path: '/leave' },
{ name: 'Pending Approvals', path: '/approval' },
],
admin: [
{ name: 'Admin Dashboard', path: '/dashboard' },
{ name: 'All Attendance Logs', path: '/attendance' },
{ name: 'All Leave Requests', path: '/leave' },
{ name: 'Attendance Summary Report', path: '/attendance-summary' },
{ name: 'Leave Summary Report', path: '/leave-summary' },
{ name: 'Manage Employees', path: '/manage-employees' },
],
superadmin: [
{ name: 'SuperAdmin Dashboard', path: '/dashboard' },
{ name: 'Global Attendance Logs', path: '/attendance' },
{ name: 'Global Leave Requests', path: '/leave' },
{ name: 'Global Attendance Summary', path: '/attendance-summary' },
{ name: 'Global Leave Summary', path: '/leave-summary' },
{ name: 'Manage Admins', path: '/manage-admins' },
{ name: 'Manage Employees', path: '/manage-employees' },
{ name: 'Policy and Rules', path: '/policies' },
],
};
export default function Navbar() {
@ -30,7 +47,7 @@ export default function Navbar() {
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const roles: UserRole[] = ['employee', 'manager', 'hrmanager', 'director'];
const roles: UserRole[] = ['employee', 'manager', 'admin', 'superadmin'];
useEffect(() => {
if (isSearchOpen && searchInputRef.current) {
@ -49,18 +66,10 @@ export default function Navbar() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Dynamically generate search items based on the current role's sidebar configuration
const availableItems = useMemo(() => {
const sidebarItems = getSidebarItems(currentRole);
return flattenNavItems(sidebarItems);
}, [currentRole]);
const searchResults = useMemo(() => {
if (!searchQuery) return [];
return availableItems.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [availableItems, searchQuery]);
const availableItems = roleSearchData[currentRole] || [];
const searchResults = availableItems.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSearchClick = (path: string) => {
navigate(path);
@ -69,29 +78,29 @@ export default function Navbar() {
};
return (
<div className="bg-app-card shadow-sm h-16 flex items-center justify-between px-4 md:px-6 border-b border-app-border relative z-30">
<div className="bg-app-card shadow-sm h-16 flex items-center justify-between px-6 border-b border-app-border relative z-30">
<div className="flex items-center shrink-0">
<h2 className="text-lg font-bold text-primary tracking-wide">CliniLaunch HRMS</h2>
<div className="flex items-center">
<h2 className="text-lg font-bold text-primary tracking-wide">CLRI HRMS</h2>
</div>
<div className="flex items-center space-x-2 md:space-x-4 ml-auto">
<div className="flex items-center space-x-4">
<div className="relative flex items-center">
{isSearchOpen ? (
<div className="flex items-center border border-app-border rounded-lg shadow-sm bg-app-card">
<Search size={18} className="ml-3 text-text-light shrink-0" />
<Search size={18} className="ml-3 text-text-light" />
<input
ref={searchInputRef}
type="text"
placeholder="Search ....."
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-32 sm:w-44 px-3 py-2 text-sm focus:outline-none rounded-lg text-text-primary min-w-0"
className="w-56 px-3 py-2 text-sm focus:outline-none rounded-lg text-text-primary"
/>
<button
onClick={() => { setIsSearchOpen(false); setSearchQuery(''); }}
className="p-2 text-text-light hover:text-text-muted shrink-0"
className="p-2 text-text-light hover:text-text-muted"
>
<X size={18} />
</button>
@ -99,14 +108,14 @@ export default function Navbar() {
) : (
<button
onClick={() => setIsSearchOpen(true)}
className="p-2 text-text-secondary hover:bg-app-muted rounded-full transition-colors shrink-0"
className="p-2 text-text-secondary hover:bg-app-muted rounded-full transition-colors"
>
<Search size={22} />
</button>
)}
{isSearchOpen && searchQuery && (
<div className="absolute right-0 top-14 w-72 bg-app-card border border-app-border rounded-lg shadow-lg overflow-hidden z-50 max-h-96 overflow-y-auto">
<div className="absolute right-0 top-14 w-72 bg-app-card border border-app-border rounded-lg shadow-lg overflow-hidden z-50">
{searchResults.length > 0 ? (
<ul>
{searchResults.map((item) => (
@ -130,7 +139,7 @@ export default function Navbar() {
)}
</div>
<div className="relative shrink-0">
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center space-x-2 p-1 rounded-lg hover:bg-app-muted transition-colors"
@ -145,7 +154,7 @@ export default function Navbar() {
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-app-card border border-app-border rounded-lg shadow-lg z-50">
<p className="px-4 py-2 text-xs font-semibold text-text-light uppercase">Switch Role</p>
<p className="px-4 py-2 text-xs font-semibold text-text-light uppercase">Switch Role (Test)</p>
{roles.map((role) => (
<button
key={role}

View File

@ -1,156 +1,80 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from '../../store/store';
import { getSidebarItems, type NavItem } from './types/sidebarConfig';
import { getSidebarItems } from '../../types/sidebarConfig';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard, CalendarCheck, Plane, CheckCircle, BarChart3, ClipboardList,
Menu, Users, ShieldUser, ScrollText, ChevronDown, FileText, CalendarDays, ClipboardCheck,
Database, Building, Network, Briefcase, Tag, ClipboardType, ClipboardMinus, History
Menu, Users, ShieldUser, ScrollText
} from 'lucide-react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const iconMap: Record<string, any> = {
LayoutDashboard, CalendarCheck, Plane, CheckCircle, BarChart3, ClipboardList,
Users, ShieldUser, ScrollText, FileText, CalendarDays, ClipboardCheck,
Database, Building, Network, Briefcase, Tag, ClipboardType, ClipboardMinus, History
LayoutDashboard,
CalendarCheck,
Plane,
CheckCircle,
BarChart3,
ClipboardList,
Users,
ShieldUser,
ScrollText,
};
export default function Sidebar({ isCollapsed, setIsCollapsed }: { isCollapsed: boolean; setIsCollapsed: (v: boolean) => void }) {
interface SidebarProps {
isCollapsed: boolean;
setIsCollapsed: (value: boolean) => void;
}
export default function Sidebar({ isCollapsed, setIsCollapsed }: SidebarProps) {
const role = useSelector((state: RootState) => state.role.currentRole);
const navItems = getSidebarItems(role);
const location = useLocation();
const currentPath = location.pathname;
const [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>({});
const [pinnedFlyout, setPinnedFlyout] = useState<string | null>(null);
// Close any pinned flyout when the route changes or the sidebar is expanded
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPinnedFlyout(null);
}, [location.pathname, isCollapsed]);
const toggleDropdown = (name: string) => {
setOpenDropdowns(prev => ({ ...prev, [name]: !prev[name] }));
};
const renderNavItem = (item: NavItem) => {
const Icon = iconMap[item.icon];
const hasChildren = item.children && item.children.length > 0;
const isDropdownOpen = openDropdowns[item.name];
const isPinned = pinnedFlyout === item.name;
const isChildActive = hasChildren && item.children!.some(child => currentPath === child.path);
const isActive = currentPath === item.path;
// COLLAPSED STATE: Render Flyout Submenus
if (isCollapsed) {
return (
<li key={item.name} className="mb-1 relative group">
{hasChildren ? (
<button
onClick={() => setPinnedFlyout(prev => prev === item.name ? null : item.name)}
className={`w-full flex items-center justify-center px-4 py-3 transition-colors duration-200 ${isChildActive || isPinned ? 'text-white border-l-4 border-action' : 'text-blue-100 hover:bg-sidebar-hover hover:text-white border-l-4 border-transparent'
}`}
>
{Icon && <Icon size={20} className="shrink-0" />}
</button>
) : (
<Link
to={item.path!}
className={`w-full flex items-center justify-center px-4 py-3 transition-colors duration-200 ${isActive ? 'bg-sidebar-active text-white border-l-4 border-action' : 'text-blue-100 hover:bg-sidebar-hover hover:text-white border-l-4 border-transparent'
}`}
>
{Icon && <Icon size={20} className="flex-shrink-0" />}
</Link>
)}
<div
className={`${isPinned ? 'block' : 'hidden'} group-hover:block absolute left-full ml-4 top-0 z-50 shadow-lg`}
>
{hasChildren ? (
<ul className="bg-sidebar min-w-[200px] rounded-lg border border-sidebar-hover py-2">
{item.children!.map(child => {
const ChildIcon = iconMap[child.icon];
return (
<li key={child.name}>
<Link
to={child.path!}
className={`flex items-center px-4 py-2 text-sm w-full transition-colors ${currentPath === child.path ? 'bg-sidebar-active text-white' : 'text-blue-100 hover:bg-sidebar-hover hover:text-white'
}`}
>
{ChildIcon && <ChildIcon size={16} className="mr-3" />}
{child.name}
</Link>
</li>
);
})}
</ul>
) : (
<div className="bg-sidebar-active text-white text-sm px-3 py-1 rounded whitespace-nowrap pointer-events-none">
{item.name}
</div>
)}
</div>
</li>
);
}
// EXPANDED STATE: Render Normal Dropdowns
return (
<li key={item.name} className="mb-1 relative group">
{hasChildren ? (
<button
onClick={() => toggleDropdown(item.name)}
className={`w-full flex items-center px-4 py-3 transition-colors duration-200 text-left ${isChildActive ? 'text-white' : 'text-blue-100 hover:bg-sidebar-hover hover:text-white'
} border-l-4 ${isChildActive ? 'border-action' : 'border-transparent'}`}
>
{Icon && <Icon size={20} className="flex-shrink-0" />}
<span className="ml-4 text-sm font-medium flex-grow">{item.name}</span>
<ChevronDown size={16} className={`transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} />
</button>
) : (
<Link
to={item.path!}
className={`flex items-center px-4 py-3 transition-colors duration-200 text-left ${isActive ? 'bg-sidebar-active text-white border-l-4 border-action' : 'text-blue-100 hover:bg-sidebar-hover hover:text-white border-l-4 border-transparent'
}`}
>
{Icon && <Icon size={20} className="flex-shrink-0" />}
<span className="ml-4 text-sm font-medium">{item.name}</span>
</Link>
)}
{isDropdownOpen && hasChildren && (
<ul className="mt-1 ml-4 border-l border-blue-800/50 pl-2">
{item.children!.map(child => renderNavItem(child))}
</ul>
)}
</li>
);
};
return (
<div className={`bg-sidebar text-white h-screen transition-all duration-300 flex flex-col ${isCollapsed ? 'w-20' : 'w-64'}`}>
<div className={`flex items-center h-16 border-b border-primary/30 px-4 flex-shrink-0 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && (
<Link to="/dashboard" className="flex items-center flex-1 overflow-hidden">
<img
src="/logo.gif"
alt="Company Logo"
className="h-14 w-4xl max-w-75 cursor-pointer "
/>
<Link to="/dashboard">
<img src="clri_logo.svg" alt="Company Logo" className="h-8 w-auto cursor-pointer" />
</Link>
)}
<button onClick={() => setIsCollapsed(!isCollapsed)} className="p-2 rounded hover:bg-sidebar-hover transition-colors flex-shrink-0">
<Menu size={24} className="text-white" />
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded hover:bg-sidebar-hover transition-colors"
>
<Menu size={24} />
</button>
</div>
<nav className={`flex-1 mt-4 ${isCollapsed ? 'overflow-x-visible' : 'overflow-y-auto overflow-x-hidden'}`}>
<nav className="flex-1 mt-4 overflow-x-visible">
<ul>
{navItems.map(item => renderNavItem(item))}
{navItems.map((item) => {
const Icon = iconMap[item.icon];
const isActive = currentPath === item.path;
return (
<li key={item.name} className="mb-1 relative group">
<Link
to={item.path}
className={`flex items-center px-4 py-3 transition-colors duration-200 ${isCollapsed ? 'justify-center' : ''} ${isActive
? 'bg-sidebar-active text-white border-l-4 border-action'
: 'text-blue-100 hover:bg-sidebar-hover hover:text-white border-l-4 border-transparent'
}`}
>
{Icon && <Icon size={20} className="flex-shrink-0" />}
{!isCollapsed && <span className="ml-4 text-sm font-medium">{item.name}</span>}
</Link>
{isCollapsed && (
<div className="absolute left-full ml-4 top-1/2 -translate-y-1/2 z-50 bg-sidebar-active text-white text-sm px-3 py-1 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
{item.name}
</div>
)}
</li>
);
})}
</ul>
</nav>
</div>

View File

@ -1,98 +0,0 @@
import type { UserRole } from '../../../store/roleSlice';
export interface NavItem {
name: string;
icon: string;
path?: string;
children?: NavItem[];
}
export const getSidebarItems = (role: UserRole): NavItem[] => {
if (role === 'employee') {
return [
{ name: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
{ name: 'Attendance Summary', icon: 'CalendarCheck', path: '/attendance' },
{ name: 'Leave Summary', icon: 'ClipboardMinus', path: '/leave' },
{ name: 'Policy and Rules', icon: 'ScrollText', path: '/policies' },
];
}
if (role === 'manager') {
return [
{ name: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
{ name: 'Attendance Summary', icon: 'CalendarCheck', path: '/attendance' },
{
name: 'Leave Service',
icon: 'ClipboardMinus',
children: [
{ name: 'Leave Summary', icon: 'ClipboardMinus', path: '/leave' },
{ name: 'Approve Leaves', icon: 'CheckCircle', path: '/approval' },
]
},
{ name: 'Policy and Rules', icon: 'ScrollText', path: '/policies' },
];
}
const attendanceSummaryItems: NavItem = {
name: 'Attendance Service',
icon: 'BarChart3',
children: [
{ name: 'Attendance Dashboard', icon: 'LayoutDashboard', path: '/attendance-summary/dashboard' },
{ name: 'Attendance Summary', icon: 'CalendarCheck', path: '/attendance' },
{ name: 'Daily Report', icon: 'CalendarDays', path: '/attendance-summary/daily' },
{ name: 'Monthly Report', icon: 'FileText', path: '/attendance-summary/monthly' },
{ name: 'Regularization History', icon: 'History', path: '/attendance-summary/regularization-history' },
...(role === 'hrmanager' ? [{ name: 'Regularization', icon: 'ClipboardCheck', path: '/attendance-summary/regularization' }] : [])
]
};
const leaveSummaryItems: NavItem = {
name: 'Leave Service',
icon: 'ClipboardList',
children: [
{ name: 'Leave Summary', icon: 'ClipboardMinus', path: '/leave' },
{ name: 'Leave Applications', icon: 'FileText', path: '/leave-summary/applications' },
{ name: 'Approve Leaves', icon: 'CheckCircle', path: '/approval' },
{ name: 'Leave Balance Report', icon: 'ClipboardType', path: '/leave-summary/balance' },
]
};
const masterDataItems: NavItem = {
name: 'Master Data Service',
icon: 'Database',
children: [
{ name: 'Master Data Dashboard', icon: 'LayoutDashboard', path: '/master-data/dashboard' },
{ name: 'Manage Company', icon: 'Building', path: '/master-data/company' },
{ name: 'Manage Branches', icon: 'Network', path: '/master-data/branches' },
{ name: 'Manage Departments', icon: 'Briefcase', path: '/master-data/departments' },
{ name: 'Manage Job Roles', icon: 'Tag', path: '/master-data/job-roles' },
{ name: 'Manage Employees', icon: 'Users', path: '/master-data/employees' },
]
};
if (role === 'hrmanager') {
return [
{ name: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
attendanceSummaryItems,
leaveSummaryItems,
masterDataItems,
{ name: 'Policy and Rules', icon: 'ScrollText', path: '/policies' },
];
}
if (role === 'director') {
return [
{ name: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
attendanceSummaryItems,
leaveSummaryItems,
masterDataItems,
{ name: 'Policy and Rules', icon: 'ScrollText', path: '/policies' },
];
}
// Fallback (should ideally not be reached if role is always valid)
return [
{ name: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
{ name: 'Policy and Rules', icon: 'ScrollText', path: '/policies' },
];
}

View File

@ -1,43 +0,0 @@
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmationModalProps {
title: string;
message: string;
onConfirm: () => void;
onClose: () => void;
confirmText?: string;
}
export default function ConfirmationModal({ title, message, onConfirm, onClose, confirmText = "Yes, Delete" }: ConfirmationModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60] p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-sm p-6 relative text-center">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-absent-100 mb-4">
<AlertTriangle className="h-6 w-6 text-absent-500" />
</div>
<h3 className="text-lg font-medium text-text-primary mb-2">{title}</h3>
<p className="text-sm text-text-muted mb-6">{message}</p>
<div className="flex justify-center space-x-3">
<button
onClick={onClose}
className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm font-medium"
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@ -10,8 +10,10 @@ export default function ErrorState({
fullScreen = false
}: ErrorStateProps) {
return (
<div className={`flex flex-col items-center justify-center bg-app-card rounded-xl shadow-md border border-app-border p-8 text-absent-600 ${fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
}`}>
<div className={`flex flex-col items-center justify-center bg-app-card rounded-xl shadow-md border border-app-border p-8 text-absent-600 ${
fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
}`}>
{/* Icon wrapped in a circular background to look like a card illustration */}
<div className="p-3 bg-absent-100 rounded-full mb-4">
<AlertCircle size={32} className="text-absent-500" />
</div>

View File

@ -8,12 +8,13 @@ interface LoaderProps {
export default function Loader({
message = 'Loading...',
color = '#001045',
color = '#0A66C2', // Default to your primary brand color
fullScreen = false
}: LoaderProps) {
return (
<div className={`flex flex-col items-center justify-center bg-app-card rounded-xl shadow-md border border-app-border p-8 ${fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
}`}>
<div className={`flex flex-col items-center justify-center bg-app-card rounded-xl shadow-md border border-app-border p-8 ${
fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
}`}>
<FadeLoader
color={color}
height={15}

View File

@ -1,23 +0,0 @@
import type { LucideIcon } from 'lucide-react';
interface StatCardProps {
title: string;
value: number;
Icon: LucideIcon;
colorClass: string;
bgClass: string;
}
export default function StatCard({ title, value, Icon, colorClass, bgClass }: StatCardProps) {
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm flex items-center justify-between transition-transform hover:scale-[1.02]">
<div>
<p className="text-sm text-text-muted font-medium">{title}</p>
<p className={`text-3xl font-bold mt-2 ${colorClass}`}>{value}</p>
</div>
<div className={`p-3 rounded-full ${bgClass}`}>
<Icon className={colorClass} size={28} />
</div>
</div>
);
}

View File

@ -1,234 +1,29 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import type { AttendanceRecord, AttendanceStatus, AttendanceSummary, DashboardMetrics, DailyReportRecord, AdminReportRecord, DailyReportFilters, AdminReportFilters, RegularizationPayload, RegularizationRecord } from '../types/attendance';
import { useQuery } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import { fetchMockData } from '../../../api/client';
import type { RootState } from '../../../store/store';
import type { UserRole } from '../../../store/roleSlice';
import type { AttendanceRecord } from '../../../types/attendance';
// Helper to map backend statuses to frontend statuses
const mapStatus = (backendStatus: string): AttendanceStatus => {
switch (backendStatus) {
case 'FULL_DAY': return 'Present';
case 'MISPUNCH': return 'Mispunch';
case 'HALF_DAY': return 'Half Day';
case 'ABSENT': return 'Absent';
case 'LEAVE': return 'Leave';
case 'HOLIDAY': return 'Holiday';
default: return 'Absent';
}
// 1. Define endpoints per role (Easy to swap to real API URLs later)
const ATTENDANCE_ENDPOINTS: Record<UserRole, string> = {
employee: 'employee_attendance',
manager: 'manager_attendance',
admin: 'admin_attendance',
superadmin: 'superadmin_attendance',
// Future real backend example:
// employee: '/api/v1/attendance/me',
// manager: '/api/v1/attendance/team',
};
export const useEmployeeRangeReport = (employeeId: number | null, fromDate: string, toDate: string) => {
// 2. Create a single dynamic hook
export const useAttendanceData = () => {
const role = useSelector((state: RootState) => state.role.currentRole);
return useQuery<AttendanceRecord[]>({
queryKey: ['employeeRangeReport', employeeId, fromDate, toDate],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/employee-range-report', {
params: { employee_id: employeeId, from_date: fromDate, to_date: toDate }
});
const history = response.data.history || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return history.map((h: any) => ({
date: h.workDate,
checkIn: h.checkIn ? h.checkIn.substring(0, 5) : '-',
checkOut: h.checkOut ? h.checkOut.substring(0, 5) : '-',
totalHours: h.workedHours ? `${Math.floor(h.workedHours)}h ${Math.round((h.workedHours % 1) * 60)}m` : '-',
status: mapStatus(h.finalStatus),
attendanceId: h.attendanceId || 0
}));
},
enabled: !!employeeId && !!fromDate && !!toDate,
});
};
export const useAttendanceSummary = (employeeId: number, startDate: string, endDate: string) => {
return useQuery<AttendanceSummary>({
queryKey: ['attendanceSummary', employeeId, startDate, endDate],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/my-summary', {
params: { employee_id: employeeId, start_date: startDate, end_date: endDate }
});
const data = response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const history: AttendanceRecord[] = data.history.map((h: any) => ({
date: h.workDate,
checkIn: h.checkIn ? h.checkIn.substring(0, 5) : '-',
checkOut: h.checkOut ? h.checkOut.substring(0, 5) : '-',
totalHours: h.workedHours ? `${Math.floor(h.workedHours)}h ${Math.round((h.workedHours % 1) * 60)}m` : '-',
status: mapStatus(h.finalStatus),
attendanceId: h.attendanceId || 0
}));
return { metrics: data.metrics, history };
},
enabled: !!employeeId && !!startDate && !!endDate,
});
};
export const useDashboardMetrics = (date: string, companyId: string, branchId: string, departmentId: string) => {
return useQuery<DashboardMetrics>({
queryKey: ['dashboardMetrics', date, companyId, branchId, departmentId],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/dashboard/metrics', {
params: {
date,
company_id: companyId || undefined,
branch_id: branchId || undefined,
department_id: departmentId || undefined
}
});
const rawData = response.data?.data || response.data;
return {
total_employees: rawData?.total_employees ?? 0,
total_checked_in: rawData?.total_checked_in ?? 0,
total_not_checked_in: rawData?.total_not_checked_in ?? 0,
} as DashboardMetrics;
},
enabled: !!date,
});
};
export const useDailyReport = (filters: DailyReportFilters) => {
return useQuery<DailyReportRecord[]>({
queryKey: ['dailyReport', filters],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/daily-report', {
params: {
date: filters.date,
company_id: filters.companyId || undefined,
branch_id: filters.branchId || undefined,
department_id: filters.departmentId || undefined,
designation: filters.designationId || undefined,
employee_name: filters.employeeName || undefined,
employee_code: filters.employeeCode || undefined,
}
});
return response.data.roster || [];
},
enabled: !!filters.date,
});
};
export const useAdminReport = (filters: AdminReportFilters) => {
return useQuery<AdminReportRecord[]>({
queryKey: ['adminReport', filters],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/admin-report', {
params: {
from_date: filters.fromDate,
to_date: filters.toDate,
company_id: filters.companyId || undefined,
branch_id: filters.branchId || undefined,
department_id: filters.departmentId || undefined,
designation: filters.designation || undefined,
employee_name: filters.employeeName || undefined,
employee_code: filters.employeeCode || undefined,
}
});
return response.data.report || [];
},
enabled: !!filters.fromDate && !!filters.toDate,
});
};
export const useCreateRegularization = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: RegularizationPayload) => {
const response = await apiClient.post('/api/ams/attendance/regularize', payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employeeRangeReport'] });
queryClient.invalidateQueries({ queryKey: ['attendanceSummary'] });
queryClient.invalidateQueries({ queryKey: ['HRManagerRegularizations'] });
},
});
};
export const useHRManagerRegularizations = () => {
return useQuery<RegularizationRecord[]>({
queryKey: ['HRManagerRegularizations'],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/regularize/pending');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((r: any) => {
const targetDate = r.targetDate ? new Date(r.targetDate) : new Date();
const reqCheckInDate = r.requestedCheckIn ? new Date(r.requestedCheckIn) : new Date();
const reqCheckOutDate = r.requestedCheckOut ? new Date(r.requestedCheckOut) : new Date();
const formattedDate = targetDate.toISOString().split('T')[0];
const formatTime = (date: Date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const capitalizeStatus = (status: string) => status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : 'Pending';
return {
id: String(r.regularizationId),
empId: r.employeeCode || String(r.employeeId),
empName: `${r.firstName || ''} ${r.lastName || ''}`.trim(),
date: formattedDate,
regularizationType: r.regularizationType ? r.regularizationType.replace('_', ' ') : '-',
reqCheckIn: formatTime(reqCheckInDate),
reqCheckOut: formatTime(reqCheckOutDate),
status: capitalizeStatus(r.status) as 'Pending' | 'Approved' | 'Rejected',
branchName: r.branchName || 'N/A',
reason: r.reason,
};
});
},
});
};
export const useReviewRegularization = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, status }: { id: string; status: 'Approved' | 'Rejected' }) => {
const action = status.toUpperCase();
const response = await apiClient.post('/api/ams/attendance/regularize/review', {
regularization_id: Number(id),
action: action
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['HRManagerRegularizations'] });
},
});
};
export const useRegularizationHistory = () => {
return useQuery<RegularizationRecord[]>({
queryKey: ['regularizationHistory'],
queryFn: async () => {
const response = await apiClient.get('/api/ams/attendance/regularize/history');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((r: any) => {
const targetDate = r.targetDate ? new Date(r.targetDate) : new Date();
const reqCheckInDate = r.requestedCheckIn ? new Date(r.requestedCheckIn) : new Date();
const reqCheckOutDate = r.requestedCheckOut ? new Date(r.requestedCheckOut) : new Date();
const formattedDate = targetDate.toISOString().split('T')[0];
const formatTime = (date: Date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const capitalizeStatus = (status: string) => status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : 'Pending';
return {
id: String(r.regularizationId),
empId: r.employeeCode || String(r.employeeId),
empName: `${r.firstName || ''} ${r.lastName || ''}`.trim(),
date: formattedDate,
regularizationType: r.regularizationType ? r.regularizationType.replace('_', ' ') : '-', // Format HALF_DAY to HALF DAY
reqCheckIn: formatTime(reqCheckInDate),
reqCheckOut: formatTime(reqCheckOutDate),
status: capitalizeStatus(r.status) as 'Pending' | 'Approved' | 'Rejected',
branchName: r.branchName || 'N/A',
reviewerName: r.reviewerName || '-',
reason: r.reason || '',
};
});
},
// Cache key includes the role so React Query caches them separately
queryKey: ['attendanceRecords', role],
queryFn: () => fetchMockData<AttendanceRecord[]>(ATTENDANCE_ENDPOINTS[role]),
});
};

View File

@ -1,54 +1,80 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from 'lucide-react';
import type { AttendanceRecord, SortKey } from '../../types/attendance';
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { AttendanceRecord, SortKey } from '../../../types/attendance';
type SortDirection = 'ascending' | 'descending';
interface AttendanceRecordsTableProps {
records: AttendanceRecord[];
title?: string;
fromDate: string;
toDate: string;
onFromDateChange: (date: string) => void;
onToDateChange: (date: string) => void;
isLoading?: boolean;
initialFromDate?: string;
initialToDate?: string;
}
// Helper to format date to YYYY-MM-DD safely without timezone shifts
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Helper to calculate days difference between two dates
const getDaysDifference = (start: string, end: string) => {
if (!start || !end) return 0;
const startDate = new Date(start + 'T00:00:00');
const endDate = new Date(end + 'T00:00:00');
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 to include start date
return diffDays;
};
// Helper to define sort priority for statuses
const getStatusPriority = (status: string) => {
const priorities: Record<string, number> = {
'Present': 1, 'Absent': 2, 'Late': 3, 'Half Day': 4, 'WFH': 5, 'Leave': 6, 'Mispunch': 7, 'Holiday': 8
'Present': 1,
'Absent': 2,
'Late': 3,
'Half Day': 4,
'WFH': 5,
'Leave': 6,
'Mispunch': 7,
'Holiday': 8
};
return priorities[status] || 99;
return priorities[status] || 99; // Unknown statuses go to the end
};
export default function AttendanceRecordsTable({
records,
title = "Attendance Records",
fromDate,
toDate,
onFromDateChange,
onToDateChange,
isLoading = false
initialFromDate,
initialToDate
}: AttendanceRecordsTableProps) {
// Default to TODAY'S DATE if no initial dates are provided
const todayStr = formatDate(new Date());
const defaultFrom = initialFromDate || todayStr;
const defaultTo = initialToDate || todayStr;
const [fromDate, setFromDate] = useState(defaultFrom);
const [toDate, setToDate] = useState(defaultTo);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'date', direction: 'ascending'
key: 'date',
direction: 'ascending'
});
// Pagination State
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const [pageSize, setPageSize] = useState(5); // Default to 5 entries per page
const filteredData = useMemo(() => {
return records.filter((record) => record.date >= fromDate && record.date <= toDate);
}, [records, fromDate, toDate]);
const sortedData = useMemo(() => {
const sortableData = [...records];
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
// Custom sorting logic for the 'status' column
if (sortConfig.key === 'status') {
const priorityA = getStatusPriority(a.status);
const priorityB = getStatusPriority(b.status);
@ -56,33 +82,44 @@ export default function AttendanceRecordsTable({
if (priorityA > priorityB) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
}
// Default sorting for other columns (dates, strings)
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [records, sortConfig]);
}, [filteredData, sortConfig]);
// Calculate if pagination should be enabled
const daysDifference = getDaysDifference(fromDate, toDate);
// Enable pagination if range > 7 days OR if records exceed the selected page size
const isPaginationEnabled = daysDifference > 7 || sortedData.length > pageSize;
const totalPages = isPaginationEnabled ? Math.ceil(sortedData.length / pageSize) : 1;
// Slice data for current page if pagination is enabled
const paginatedData = useMemo(() => {
if (!isPaginationEnabled) return sortedData;
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, isPaginationEnabled, pageSize]);
// Calculate which page numbers to show (e.g., 1, 2, 3, 4, 5)
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
}, [currentPage, totalPages]);
@ -90,12 +127,23 @@ export default function AttendanceRecordsTable({
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
setCurrentPage(1); // Reset page on sort
};
const handleFromDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { onFromDateChange(e.target.value); setCurrentPage(1); };
const handleToDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { onToDateChange(e.target.value); setCurrentPage(1); };
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => { setPageSize(Number(e.target.value)); setCurrentPage(1); };
const handleFromDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFromDate(e.target.value);
setCurrentPage(1); // Reset page on filter change
};
const handleToDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setToDate(e.target.value);
setCurrentPage(1); // Reset page on filter change
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1); // Reset to page 1 when page size changes
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
@ -121,15 +169,26 @@ export default function AttendanceRecordsTable({
<div className="bg-app-card p-6 rounded-lg shadow-sm mt-6">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">{title}</h2>
<div className="flex items-center space-x-2">
<div className="relative">
<Calendar size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="date" value={fromDate} onChange={handleFromDateChange} className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
<input
type="date"
value={fromDate}
onChange={handleFromDateChange}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
/>
</div>
<span className="text-text-muted text-sm">to</span>
<div className="relative">
<Calendar size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="date" value={toDate} onChange={handleToDateChange} className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
<input
type="date"
value={toDate}
onChange={handleToDateChange}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
/>
</div>
</div>
</div>
@ -138,8 +197,12 @@ export default function AttendanceRecordsTable({
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['date', 'checkIn', 'checkOut', 'totalHours', 'status'] as SortKey[]).map((key) => (
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
{(['date', 'timeIn', 'timeOut', 'loggedHours', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} {getSortIcon(key)}
</div>
@ -148,42 +211,43 @@ export default function AttendanceRecordsTable({
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{isLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-sm text-text-muted">
<div className="flex items-center justify-center space-x-2">
<Loader2 size={20} className="animate-spin text-primary" />
<span>Loading records...</span>
</div>
</td>
</tr>
) : paginatedData.length > 0 ? (
{paginatedData.length > 0 ? (
paginatedData.map((record, index) => (
<tr key={index} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">
{new Date(record.date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
{new Date(record.date + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.checkIn}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.checkOut}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-primary">{record.totalHours}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.timeIn}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.timeOut}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-primary">{record.loggedHours}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>
{record.status}
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-sm text-text-muted">No records found for the selected period.</td>
<td colSpan={5} className="px-6 py-8 text-center text-sm text-text-muted">
No records found for the selected period.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer: Entries per page & Pagination Controls */}
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
{/* Show Entries Dropdown */}
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<select
value={pageSize}
onChange={handlePageSizeChange}
className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={15}>15</option>
@ -192,16 +256,52 @@ export default function AttendanceRecordsTable({
<span className="text-sm text-text-muted">entries</span>
</div>
{/* Pagination Buttons */}
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm-inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
<span className="text-sm text-text-muted mr-2 hidden sm:inline">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronsLeft size={16} />
</button>
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
<button
key={num}
onClick={() => setCurrentPage(num)}
className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num
? 'bg-primary text-white border-primary'
: 'border-app-border text-text-secondary hover:bg-app-muted'
}`}
>
{num}
</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronsRight size={16} />
</button>
</div>
)}
</div>

View File

@ -1,13 +1,11 @@
import { useState } from 'react';
import { CalendarCheck } from 'lucide-react';
interface CalendarProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
selectedDate: string;
currentMonth: number;
currentYear: number;
onDateSelect: (date: string) => void;
onMonthChange: (month: number, year: number) => void;
}
const formatDate = (date: Date) => {
@ -17,7 +15,11 @@ const formatDate = (date: Date) => {
return `${year}-${month}-${day}`;
};
export default function Calendar({ data, selectedDate, currentMonth, currentYear, onDateSelect, onMonthChange }: CalendarProps) {
export default function Calendar({ data, selectedDate, onDateSelect }: CalendarProps) {
const initialDate = new Date(selectedDate);
const [currentMonth, setCurrentMonth] = useState(initialDate.getMonth());
const [currentYear, setCurrentYear] = useState(initialDate.getFullYear());
const today = formatDate(new Date());
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
@ -32,23 +34,18 @@ export default function Calendar({ data, selectedDate, currentMonth, currentYear
const goToToday = () => {
const todayDate = new Date();
onMonthChange(todayDate.getMonth(), todayDate.getFullYear());
setCurrentMonth(todayDate.getMonth());
setCurrentYear(todayDate.getFullYear());
onDateSelect(formatDate(todayDate));
};
const handleMonthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onMonthChange(Number(e.target.value), currentYear);
};
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onMonthChange(currentMonth, Number(e.target.value));
};
const getStatusClass = (dateStr: string, dayOfWeek: number) => {
const dayData = data[dateStr];
if (dayData?.status === 'Present') return 'bg-present-100 text-present-800 border-present-300 hover:bg-present-200';
if (dayData?.status === 'Late') return 'bg-late-100 text-late-800 border-late-300 hover:bg-late-200';
if (dayData?.status === 'Half Day') return 'bg-halfday-100 text-halfday-800 border-halfday-300 hover:bg-halfday-200';
if (dayData?.status === 'WFH') return 'bg-wfh-100 text-wfh-800 border-wfh-300 hover:bg-wfh-200';
if (dayData?.status === 'Absent') return 'bg-absent-100 text-absent-800 border-absent-300 hover:bg-absent-200';
if (dayData?.status === 'Leave') return 'bg-leave-100 text-leave-800 border-leave-300 hover:bg-leave-200';
if (dayData?.status === 'Mispunch') return 'bg-mispunch-100 text-mispunch-800 border-mispunch-300 hover:bg-mispunch-200';
@ -64,15 +61,15 @@ export default function Calendar({ data, selectedDate, currentMonth, currentYear
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<h2 className="text-lg font-semibold text-text-primary">Attendance</h2>
<h2 className="text-lg font-semibold text-text-primary">My Attendance</h2>
<div className="flex items-center space-x-2">
<button onClick={goToToday} className="flex items-center px-3 py-2 text-sm font-medium text-text-secondary border border-app-border rounded-lg hover:bg-app-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors">
<CalendarCheck size={16} className="mr-2 text-text-muted" /> Today
</button>
<select value={currentMonth} onChange={handleMonthChange} className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<select value={currentMonth} onChange={(e) => setCurrentMonth(Number(e.target.value))} className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
{monthsArray.map((month, idx) => <option key={idx} value={idx}>{month}</option>)}
</select>
<select value={currentYear} onChange={handleYearChange} className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<select value={currentYear} onChange={(e) => setCurrentYear(Number(e.target.value))} className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
{yearsArray.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</div>
@ -100,7 +97,9 @@ export default function Calendar({ data, selectedDate, currentMonth, currentYear
<div className="mt-8 pt-6 border-t border-app-border flex flex-wrap gap-4">
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-present-100 border border-present-300"></div><span className="text-xs text-text-secondary">Present</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-late-100 border border-late-300"></div><span className="text-xs text-text-secondary">Late Arrival</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-halfday-100 border border-halfday-300"></div><span className="text-xs text-text-secondary">Half Day</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-wfh-100 border border-wfh-300"></div><span className="text-xs text-text-secondary">Work From Home</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-absent-100 border border-absent-300"></div><span className="text-xs text-text-secondary">Absent</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-leave-100 border border-leave-300"></div><span className="text-xs text-text-secondary">Leave</span></div>
<div className="flex items-center space-x-2"><div className="w-4 h-4 rounded bg-mispunch-100 border border-mispunch-300"></div><span className="text-xs text-text-secondary">Mispunch</span></div>

View File

@ -1,8 +1,4 @@
import { useState } from 'react';
import { LogIn, LogOut, Clock, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import RegularizationModal from './RegularizationModal';
import { useCreateRegularization } from '../../api/useAttendanceData';
import { LogIn, LogOut, Clock } from 'lucide-react';
interface DailySummaryProps {
selectedDate: string;
@ -11,35 +7,12 @@ interface DailySummaryProps {
login: string;
logout: string;
hours: string;
attendanceId: number;
} | null;
employeeId: number;
}
export default function DailySummary({ selectedDate, data, employeeId }: DailySummaryProps) {
const selectedData = data || { status: 'No Data', login: '-', logout: '-', hours: '-', attendanceId: 0 };
const canRegularize = selectedData.status === 'Mispunch' || selectedData.status === 'Half Day';
const [isApplied, setIsApplied] = useState(false);
const [showModal, setShowModal] = useState(false);
const createRegMutation = useCreateRegularization();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleApplyRegularization = (payload: any) => {
createRegMutation.mutate(payload, {
onSuccess: () => {
setIsApplied(true);
setShowModal(false);
toast.success("Regularization request submitted successfully.");
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error?.response?.data?.message || "Failed to submit request.");
}
});
};
export default function DailySummary({ selectedDate, data }: DailySummaryProps) {
// Fallback if no data exists for the selected date
const selectedData = data || { status: 'No Data', login: '-', logout: '-', hours: '-' };
return (
<div className="w-full lg:w-96 bg-app-card p-6 rounded-lg shadow-sm h-fit">
@ -67,7 +40,9 @@ export default function DailySummary({ selectedDate, data, employeeId }: DailySu
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-app-muted rounded-lg">
<div className="flex items-center space-x-3">
<div className="p-2 bg-present-100 rounded-lg"><LogIn size={20} className="text-present-600" /></div>
<div className="p-2 bg-present-100 rounded-lg">
<LogIn size={20} className="text-present-600" />
</div>
<span className="text-sm font-medium text-text-secondary">Check-In</span>
</div>
<span className="text-sm font-bold text-text-primary">{selectedData.login}</span>
@ -75,7 +50,9 @@ export default function DailySummary({ selectedDate, data, employeeId }: DailySu
<div className="flex items-center justify-between p-4 bg-app-muted rounded-lg">
<div className="flex items-center space-x-3">
<div className="p-2 bg-absent-100 rounded-lg"><LogOut size={20} className="text-absent-600" /></div>
<div className="p-2 bg-absent-100 rounded-lg">
<LogOut size={20} className="text-absent-600" />
</div>
<span className="text-sm font-medium text-text-secondary">Check-Out</span>
</div>
<span className="text-sm font-bold text-text-primary">{selectedData.logout}</span>
@ -83,46 +60,14 @@ export default function DailySummary({ selectedDate, data, employeeId }: DailySu
<div className="flex items-center justify-between p-4 bg-primary/5 rounded-lg border border-primary/20">
<div className="flex items-center space-x-3">
<div className="p-2 bg-today-100 rounded-lg"><Clock size={20} className="text-primary" /></div>
<div className="p-2 bg-today-100 rounded-lg">
<Clock size={20} className="text-primary" />
</div>
<span className="text-sm font-medium text-text-secondary">Total Hours</span>
</div>
<span className="text-base font-extrabold text-primary">{selectedData.hours}</span>
</div>
</div>
{/* Conditional Regularization Button */}
{canRegularize && (
<div className="mt-6">
{isApplied ? (
<button
disabled
className="w-full flex items-center justify-center px-4 py-2 bg-app-muted text-text-light rounded-lg cursor-not-allowed text-sm font-medium"
>
<AlertTriangle size={16} className="mr-2" />
Applied for Regularization
</button>
) : (
<button
onClick={() => setShowModal(true)}
className="w-full flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors text-sm font-medium"
>
<AlertTriangle size={16} className="mr-2" />
Request Regularization
</button>
)}
</div>
)}
{showModal && (
<RegularizationModal
selectedDate={selectedDate}
attendanceId={selectedData.attendanceId}
employeeId={employeeId}
status={selectedData.status}
onClose={() => setShowModal(false)}
onApply={handleApplyRegularization}
/>
)}
</div>
);
}

View File

@ -1,101 +0,0 @@
import { useState } from 'react';
import { X } from 'lucide-react';
interface RegularizationModalProps {
selectedDate: string;
attendanceId: number;
employeeId: number;
status: string;
onClose: () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onApply: (payload: any) => void;
}
export default function RegularizationModal({ selectedDate, attendanceId, employeeId, status, onClose, onApply }: RegularizationModalProps) {
const [regData, setRegData] = useState({ checkIn: '', checkOut: '', reason: '' });
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setRegData(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Map frontend status to backend enum
const backendType = status === 'Half Day' ? 'HALF_DAY' : 'MISPUNCH';
// Format dates to "YYYY-MM-DD HH:mm:ss" for backend
const payload = {
attendance_id: attendanceId,
employee_id: employeeId,
regularization_type: backendType,
target_date: selectedDate,
requested_check_in: `${selectedDate} ${regData.checkIn}:00`,
requested_check_out: `${selectedDate} ${regData.checkOut}:00`,
reason: regData.reason
};
onApply(payload);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-1">Request Regularization</h3>
<p className="text-sm text-text-muted mb-6">
For Date: {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Check-In Time</label>
<input
type="time"
name="checkIn"
value={regData.checkIn}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Check-Out Time</label>
<input
type="time"
name="checkOut"
value={regData.checkOut}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Reason</label>
<textarea
name="reason"
value={regData.reason}
onChange={handleChange}
required
rows={3}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card resize-none"
placeholder={`Brief reason for ${status.toLowerCase()}...`}
></textarea>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium">
Apply
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,38 +0,0 @@
import { PieChart } from '@mui/x-charts/PieChart';
interface AttendanceChartsProps {
checkedIn: number;
notCheckedIn: number;
}
export default function AttendanceCharts({ checkedIn, notCheckedIn }: AttendanceChartsProps) {
const pieData = [
{ id: 0, value: checkedIn, label: 'Checked In', color: '#16a34a' },
{ id: 1, value: notCheckedIn, label: 'Not Checked In', color: '#dc2626' }
];
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<h3 className="text-lg font-semibold text-text-primary mb-6">Daily Attendance Ratio</h3>
<div className="h-72 w-full flex items-center justify-center">
{checkedIn === 0 && notCheckedIn === 0 ? (
<p className="text-text-muted text-sm">No data available for selected filters</p>
) : (
<PieChart
series={[
{
data: pieData,
innerRadius: 40,
outerRadius: 80,
paddingAngle: 2,
cornerRadius: 4,
arcLabel: (item) => `${item.value}`,
},
]}
height={280}
/>
)}
</div>
</div>
);
}

View File

@ -1,98 +0,0 @@
import { useMemo } from 'react';
import { Search } from 'lucide-react';
import { useCompanies } from '../../../masterData/api/useCompanyData';
import { useBranches } from '../../../masterData/api/useBranchData';
import { useDepartments } from '../../../masterData/api/useDepartmentData';
import { useJobRoles } from '../../../masterData/api/useJobRoleData';
import type { DailyReportFilters } from '../../types/attendance';
interface DailyReportFiltersProps {
filters: DailyReportFilters;
onFilterChange: (newFilters: DailyReportFilters) => void;
}
export default function DailyReportFilters({ filters, onFilterChange }: DailyReportFiltersProps) {
const { data: companies } = useCompanies();
const { data: allBranches } = useBranches();
const { data: allDepartments } = useDepartments();
const { data: designations } = useJobRoles();
const filteredBranches = useMemo(() => {
if (!allBranches || !filters.companyId) return [];
const selectedCompany = companies?.find(c => String(c.id) === filters.companyId);
if (!selectedCompany) return [];
return allBranches.filter(b => b.companyName === selectedCompany.name);
}, [allBranches, filters.companyId, companies]);
const filteredDepartments = useMemo(() => {
if (!allDepartments || !filters.branchId) return [];
const selectedBranch = filteredBranches.find(b => String(b.id) === filters.branchId);
if (!selectedBranch) return [];
return allDepartments.filter(d => d.branchName === selectedBranch.name);
}, [allDepartments, filters.branchId, filteredBranches]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
const newFilters = { ...filters, [name]: value };
// Reset child dropdowns if parent changes
if (name === 'companyId') { newFilters.branchId = ''; newFilters.departmentId = ''; }
if (name === 'branchId') { newFilters.departmentId = ''; }
onFilterChange(newFilters);
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card disabled:opacity-50 disabled:cursor-not-allowed";
const labelClass = "text-xs text-text-muted mb-1";
return (
<div className="bg-app-card p-4 rounded-lg shadow-sm grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex flex-col">
<label className={labelClass}>Date</label>
<input type="date" name="date" value={filters.date} onChange={handleChange} className={inputClass} />
</div>
<div className="flex flex-col">
<label className={labelClass}>Company</label>
<select name="companyId" value={filters.companyId} onChange={handleChange} className={inputClass}>
<option value="">All Companies</option>
{companies?.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Branch</label>
<select name="branchId" value={filters.branchId} onChange={handleChange} disabled={!filters.companyId} className={inputClass}>
<option value="">All Branches</option>
{filteredBranches.map(b => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Department</label>
<select name="departmentId" value={filters.departmentId} onChange={handleChange} disabled={!filters.branchId} className={inputClass}>
<option value="">All Departments</option>
{filteredDepartments.map(d => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Job Role</label>
<select name="designationId" value={filters.designationId} onChange={handleChange} className={inputClass}>
<option value="">All Job Roles</option>
{designations?.map(d => <option key={d.id} value={d.name}>{d.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Employee Name</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="text" name="employeeName" value={filters.employeeName} onChange={handleChange} placeholder="Search name..." className={`${inputClass} pl-9`} />
</div>
</div>
<div className="flex flex-col">
<label className={labelClass}>Employee Code</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="text" name="employeeCode" value={filters.employeeCode} onChange={handleChange} placeholder="Search code..." className={`${inputClass} pl-9`} />
</div>
</div>
</div>
);
}

View File

@ -1,138 +0,0 @@
// src/features/attendance/components/summary/DailyReportTable.tsx
import { useState, useMemo } from 'react';
import { Loader2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
import type { DailyReportRecord } from '../../types/attendance';
interface DailyReportTableProps {
records: DailyReportRecord[];
date: string;
isLoading?: boolean;
}
const getStatusClass = (status: string) => {
switch (status) {
case 'FULL_DAY': return 'bg-present-100 text-present-700';
case 'LATE': return 'bg-late-100 text-late-700';
case 'ABSENT': return 'bg-absent-100 text-absent-700';
case 'MISPUNCH': return 'bg-mispunch-100 text-mispunch-700';
case 'HALF_DAY': return 'bg-halfday-100 text-halfday-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function DailyReportTable({ records, date, isLoading = false }: DailyReportTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20); // Default 20 entries
const totalPages = Math.ceil(records.length / pageSize);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return records.slice(startIndex, startIndex + pageSize);
}, [records, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const formattedDate = new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' });
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm mt-6 overflow-x-auto">
<h3 className="text-lg font-semibold text-text-primary mb-4">
Attendance Report for {formattedDate} ({records.length})
</h3>
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Emp CODE</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Company</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Branch</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Department</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Job Role</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Check-In</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Check-Out</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Hours</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{isLoading ? (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</td>
</tr>
) : paginatedData.length > 0 ? (
paginatedData.map((record, index) => (
<tr key={index} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-3 text-sm text-text-primary">{record.employeeCode}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.fullName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.companyName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.branchName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.departmentName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.designation || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.checkIn ? record.checkIn.substring(0, 5) : '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.checkOut ? record.checkOut.substring(0, 5) : '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.workedHours}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>
{record.status.replace('_', ' ')}
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">
No attendance records found for this date.
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination Controls */}
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,102 +0,0 @@
import { useMemo } from 'react';
import { Search } from 'lucide-react';
import { useCompanies } from '../../../masterData/api/useCompanyData';
import { useBranches } from '../../../masterData/api/useBranchData';
import { useDepartments } from '../../../masterData/api/useDepartmentData';
import { useJobRoles } from '../../../masterData/api/useJobRoleData';
import type { AdminReportFilters } from '../../types/attendance';
interface MonthlyReportFiltersProps {
filters: AdminReportFilters;
onFilterChange: (newFilters: AdminReportFilters) => void;
}
export default function MonthlyReportFilters({ filters, onFilterChange }: MonthlyReportFiltersProps) {
const { data: companies } = useCompanies();
const { data: allBranches } = useBranches();
const { data: allDepartments } = useDepartments();
const { data: designations } = useJobRoles();
const filteredBranches = useMemo(() => {
if (!allBranches || !filters.companyId) return [];
const selectedCompany = companies?.find(c => String(c.id) === filters.companyId);
if (!selectedCompany) return [];
return allBranches.filter(b => b.companyName === selectedCompany.name);
}, [allBranches, filters.companyId, companies]);
const filteredDepartments = useMemo(() => {
if (!allDepartments || !filters.branchId) return [];
const selectedBranch = filteredBranches.find(b => String(b.id) === filters.branchId);
if (!selectedBranch) return [];
return allDepartments.filter(d => d.branchName === selectedBranch.name);
}, [allDepartments, filters.branchId, filteredBranches]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
const newFilters = { ...filters, [name]: value };
// Reset child dropdowns if parent changes
if (name === 'companyId') { newFilters.branchId = ''; newFilters.departmentId = ''; }
if (name === 'branchId') { newFilters.departmentId = ''; }
onFilterChange(newFilters);
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card disabled:opacity-50 disabled:cursor-not-allowed";
const labelClass = "text-xs text-text-muted mb-1";
return (
<div className="bg-app-card p-4 rounded-lg shadow-sm grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex flex-col">
<label className={labelClass}>From Date</label>
<input type="date" name="fromDate" value={filters.fromDate} onChange={handleChange} className={inputClass} />
</div>
<div className="flex flex-col">
<label className={labelClass}>To Date</label>
<input type="date" name="toDate" value={filters.toDate} onChange={handleChange} className={inputClass} />
</div>
<div className="flex flex-col">
<label className={labelClass}>Company</label>
<select name="companyId" value={filters.companyId} onChange={handleChange} className={inputClass}>
<option value="">All Companies</option>
{companies?.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Branch</label>
<select name="branchId" value={filters.branchId} onChange={handleChange} disabled={!filters.companyId} className={inputClass}>
<option value="">All Branches</option>
{filteredBranches.map(b => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Department</label>
<select name="departmentId" value={filters.departmentId} onChange={handleChange} disabled={!filters.branchId} className={inputClass}>
<option value="">All Departments</option>
{filteredDepartments.map(d => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Job Role</label>
<select name="designation" value={filters.designation} onChange={handleChange} className={inputClass}>
<option value="">All Job Roles</option>
{designations?.map(d => <option key={d.id} value={d.name}>{d.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Employee Name</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="text" name="employeeName" value={filters.employeeName} onChange={handleChange} placeholder="Search name..." className={`${inputClass} pl-9`} />
</div>
</div>
<div className="flex flex-col">
<label className={labelClass}>Employee Code</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="text" name="employeeCode" value={filters.employeeCode} onChange={handleChange} placeholder="Search code..." className={`${inputClass} pl-9`} />
</div>
</div>
</div>
);
}

View File

@ -1,124 +0,0 @@
import { useState, useMemo } from 'react';
import { Loader2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
import type { AdminReportRecord } from '../../types/attendance';
interface MonthlyReportTableProps {
records: AdminReportRecord[];
fromDate: string;
toDate: string;
isLoading?: boolean;
}
export default function MonthlyReportTable({ records, fromDate, toDate, isLoading = false }: MonthlyReportTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.ceil(records.length / pageSize);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return records.slice(startIndex, startIndex + pageSize);
}, [records, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const formattedFromDate = new Date(fromDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
const formattedToDate = new Date(toDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm mt-6 overflow-x-auto">
<h3 className="text-lg font-semibold text-text-primary mb-4">
Monthly Attendance Report from {formattedFromDate} to {formattedToDate} ({records.length})
</h3>
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Emp CODE</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Company</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Branch</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Department</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Job Role</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Full Days</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Half Days</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Late Arrivals</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Mispunches</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{isLoading ? (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</td>
</tr>
) : paginatedData.length > 0 ? (
paginatedData.map((record, index) => (
<tr key={index} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-3 text-sm text-text-primary">{record.employeeCode}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.firstName} {record.lastName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.companyName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.branchName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.departmentName || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.designation || '-'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.rangeMetrics?.fullDays || 0}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.rangeMetrics?.halfDays || 0}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.rangeMetrics?.lateArrivals || 0}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.rangeMetrics?.mispunches || 0}</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">
No attendance records found for this period.
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination Controls */}
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm-inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,82 +0,0 @@
import { X } from 'lucide-react';
import type { RegularizationRecord } from '../../types/attendance';
interface RegularizationHistoryModalProps {
record: RegularizationRecord;
onClose: () => void;
}
const getStatusClass = (status: string) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Rejected': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function RegularizationHistoryModal({ record, onClose }: RegularizationHistoryModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative max-h-[90vh] overflow-y-auto">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">Regularization Details</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Request ID</span>
<span className="text-sm font-medium text-text-primary">{record.id}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Emp Id</span>
<span className="text-sm font-medium text-text-primary">{record.empId}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Emp Name</span>
<span className="text-sm font-medium text-text-primary">{record.empName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Branch</span>
<span className="text-sm font-medium text-text-primary">{record.branchName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Date</span>
<span className="text-sm font-medium text-text-primary">{new Date(record.date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Type</span>
<span className="text-sm font-medium text-text-primary">{record.regularizationType}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Req Check In</span>
<span className="text-sm font-medium text-text-primary">{record.reqCheckIn}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Req Check Out</span>
<span className="text-sm font-medium text-text-primary">{record.reqCheckOut}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Reviewed By</span>
<span className="text-sm font-medium text-text-primary">{record.reviewerName || 'Pending Review'}</span>
</div>
<div>
<span className="text-sm text-text-muted block mb-1">Reason</span>
<p className="text-sm text-text-secondary bg-app-muted p-3 rounded-lg">{record.reason}</p>
</div>
</div>
<div className="flex justify-end mt-6">
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
</div>
</div>
</div>
);
}

View File

@ -1,198 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Eye, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { RegularizationRecord } from '../../types/attendance';
import RegularizationHistoryModal from './RegularizationHistoryModal';
interface RegularizationHistoryTableProps {
records: RegularizationRecord[];
}
type SortKey = 'empId' | 'empName' | 'branchName' | 'date' | 'regularizationType' | 'reqCheckIn' | 'reqCheckOut' | 'status' | 'reviewerName';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Rejected': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function RegularizationHistoryTable({ records }: RegularizationHistoryTableProps) {
const [viewRecord, setViewRecord] = useState<RegularizationRecord | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'date',
direction: 'descending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.empId.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.empName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const formatHeader = (key: string) => {
if (key === 'empId') return 'Emp Id';
if (key === 'empName') return 'Emp Name';
if (key === 'branchName') return 'Branch';
if (key === 'regularizationType') return 'Type';
if (key === 'reqCheckIn') return 'Req Check In';
if (key === 'reqCheckOut') return 'Req Check Out';
if (key === 'reviewerName') return 'Reviewed By';
return key.charAt(0).toUpperCase() + key.slice(1);
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm overflow-x-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">All Regularization Requests</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search id, name, status..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['empId', 'empName', 'branchName', 'date', 'regularizationType', 'reqCheckIn', 'reqCheckOut', 'status', 'reviewerName'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{formatHeader(key)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-3 text-sm text-text-primary">{record.empId}</td>
<td className="px-4 py-3 text-sm text-text-primary">{record.empName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.branchName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{new Date(record.date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.regularizationType}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.reqCheckIn}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.reqCheckOut}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.reviewerName || 'Pending Review'}</td>
<td className="px-4 py-3 text-sm">
<button onClick={() => setViewRecord(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">No regularization records found.</td>
</tr>
)}
</tbody>
</table>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
{viewRecord && (
<RegularizationHistoryModal
record={viewRecord}
onClose={() => setViewRecord(null)}
/>
)}
</div>
);
}

View File

@ -1,88 +0,0 @@
import { X, Check, Ban } from 'lucide-react';
import type { RegularizationRecord } from '../../types/attendance';
interface RegularizationModalProps {
record: RegularizationRecord;
onClose: () => void;
onAction: (id: string, status: 'Approved' | 'Rejected') => void;
}
const getStatusClass = (status: string) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Rejected': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function RegularizationModal({ record, onClose, onAction }: RegularizationModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">Regularization Request</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Request ID</span>
<span className="text-sm font-medium text-text-primary">{record.id}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Emp Id</span>
<span className="text-sm font-medium text-text-primary">{record.empId}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Emp Name</span>
<span className="text-sm font-medium text-text-primary">{record.empName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Date</span>
<span className="text-sm font-medium text-text-primary">{new Date(record.date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Req Check In</span>
<span className="text-sm font-medium text-text-primary">{record.reqCheckIn}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Req Check Out</span>
<span className="text-sm font-medium text-text-primary">{record.reqCheckOut}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</div>
<div>
<span className="text-sm text-text-muted block mb-1">Reason</span>
<p className="text-sm text-text-secondary bg-app-muted p-3 rounded-lg">{record.reason}</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
{record.status === 'Pending' ? (
<>
<button
onClick={() => onAction(record.id, 'Rejected')}
className="flex items-center px-4 py-2 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm font-medium"
>
<Ban size={16} className="mr-2" /> Reject
</button>
<button
onClick={() => onAction(record.id, 'Approved')}
className="flex items-center px-4 py-2 bg-present-600 text-white rounded-lg hover:bg-present-700 transition-colors text-sm font-medium"
>
<Check size={16} className="mr-2" /> Approve
</button>
</>
) : (
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -1,202 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Eye, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { RegularizationRecord } from '../../types/attendance';
import RegularizationModal from './RegularizationModal';
interface RegularizationTableProps {
records: RegularizationRecord[];
onAction: (id: string, status: 'Approved' | 'Rejected') => void;
}
type SortKey = 'empId' | 'empName' | 'branchName' | 'date' | 'regularizationType' | 'reqCheckIn' | 'reqCheckOut' | 'reason' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Rejected': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function RegularizationTable({ records, onAction }: RegularizationTableProps) {
const [viewRecord, setViewRecord] = useState<RegularizationRecord | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'date',
direction: 'descending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.empId.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.empName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const formatHeader = (key: string) => {
if (key === 'empId') return 'Emp Id';
if (key === 'empName') return 'Emp Name';
if (key === 'branchName') return 'Branch';
if (key === 'regularizationType') return 'Type';
if (key === 'reqCheckIn') return 'Req Check In';
if (key === 'reqCheckOut') return 'Req Check Out';
return key.charAt(0).toUpperCase() + key.slice(1);
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm overflow-x-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Pending Regularization Requests</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search id, name, status..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['empId', 'empName', 'branchName', 'date', 'regularizationType', 'reqCheckIn', 'reqCheckOut', 'reason', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{formatHeader(key)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-3 text-sm text-text-primary">{record.empId}</td>
<td className="px-4 py-3 text-sm text-text-primary">{record.empName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.branchName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{new Date(record.date + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.regularizationType}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.reqCheckIn}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.reqCheckOut}</td>
<td className="px-4 py-3 text-sm text-text-secondary max-w-xs truncate">{record.reason}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-4 py-3 text-sm">
<button onClick={() => setViewRecord(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-text-muted">No matching regularization requests found.</td>
</tr>
)}
</tbody>
</table>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
{viewRecord && (
<RegularizationModal
record={viewRecord}
onClose={() => setViewRecord(null)}
onAction={(id, status) => {
onAction(id, status);
setViewRecord(null);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
import { useState, useMemo } from 'react';
import Calendar from '../components/Calendar';
import AttendanceRecordsTable from '../components/AttendanceRecordsTable';
import DailySummary from '../components/DailySummary';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useAttendanceData } from '../api/useAttendanceData';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
export default function AdminAttendance() {
const [selectedDate, setSelectedDate] = useState(getTodayDate());
const { data: attendanceRecords, isLoading, isError } = useAttendanceData();
const calendarData = useMemo(() => {
const map: Record<string, any> = {};
if (attendanceRecords) {
attendanceRecords.forEach(record => {
map[record.date] = {
status: record.status, login: record.timeIn, logout: record.timeOut, hours: record.loggedHours
};
});
}
return map;
}, [attendanceRecords]);
if (isLoading) return <Loader message="Loading my attendance..." />;
if (isError) return <ErrorState message="Failed to load attendance data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<Calendar data={calendarData} selectedDate={selectedDate} onDateSelect={setSelectedDate} />
</div>
<DailySummary selectedDate={selectedDate} data={calendarData[selectedDate] || null} />
</div>
{attendanceRecords && <AttendanceRecordsTable records={attendanceRecords} title="My Attendance Records" />}
</div>
);
}

View File

@ -1,126 +0,0 @@
import { useState, useMemo } from 'react';
import { Users, UserCheck, UserX } from 'lucide-react';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useDashboardMetrics } from '../api/useAttendanceData';
import { useCompanies } from '../../masterData/api/useCompanyData';
import { useBranches } from '../../masterData/api/useBranchData';
import { useDepartments } from '../../masterData/api/useDepartmentData';
import StatCard from '../../../components/ui/StatCard';
import AttendanceCharts from '../components/summary/AttendanceCharts';
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function AttendanceDashboard() {
const [date, setDate] = useState(formatDate(new Date()));
// Using strings for IDs to pass to the metrics API
const [companyId, setCompanyId] = useState('');
const [branchId, setBranchId] = useState('');
const [departmentId, setDepartmentId] = useState('');
// Fetch dropdown data from EMS service
const { data: companies } = useCompanies();
const { data: allBranches } = useBranches();
const { data: allDepartments } = useDepartments();
// Cascading filter logic (Filter by Name because backend doesn't return parent IDs in list view)
const filteredBranches = useMemo(() => {
if (!allBranches || !companyId) return [];
const selectedCompany = companies?.find(c => String(c.id) === companyId);
if (!selectedCompany) return [];
// Match by companyName since companyId is missing in branch response
return allBranches.filter(b => b.companyName === selectedCompany.name);
}, [allBranches, companyId, companies]);
const filteredDepartments = useMemo(() => {
if (!allDepartments || !branchId) return [];
const selectedBranch = filteredBranches.find(b => String(b.id) === branchId);
if (!selectedBranch) return [];
// Match by branchName since branchId is missing in department response
return allDepartments.filter(d => d.branchName === selectedBranch.name);
}, [allDepartments, branchId, filteredBranches]);
// Fetch Dashboard Metrics based on selected filters
const { data: metrics, isLoading, isError } = useDashboardMetrics(date, companyId, branchId, departmentId);
const stats = {
total: metrics?.total_employees || 0,
checkedIn: metrics?.total_checked_in || 0,
notCheckedIn: metrics?.total_not_checked_in || 0,
};
if (isLoading) return <Loader message="Loading dashboard..." />;
if (isError) return <ErrorState message="Failed to load dashboard data." />;
return (
<div className="space-y-6">
{/* Grid Filter Bar */}
<div className="bg-app-card p-4 rounded-lg shadow-sm grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex flex-col">
<label className="text-xs text-text-muted mb-1">Date</label>
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
/>
</div>
{/* Company Dropdown */}
<div className="flex flex-col">
<label className="text-xs text-text-muted mb-1">Company</label>
<select
value={companyId}
onChange={e => { setCompanyId(e.target.value); setBranchId(''); setDepartmentId(''); }}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
<option value="">All Companies</option>
{companies?.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
</select>
</div>
{/* Branch Dropdown */}
<div className="flex flex-col">
<label className="text-xs text-text-muted mb-1">Branch</label>
<select
value={branchId}
onChange={e => { setBranchId(e.target.value); setDepartmentId(''); }}
disabled={!companyId}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">All Branches</option>
{filteredBranches.map(b => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
</div>
{/* Department Dropdown */}
<div className="flex flex-col">
<label className="text-xs text-text-muted mb-1">Department</label>
<select
value={departmentId}
onChange={e => setDepartmentId(e.target.value)}
disabled={!branchId}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">All Departments</option>
{filteredDepartments.map(d => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard title="Total Employees" value={stats.total} Icon={Users} colorClass="text-primary" bgClass="bg-primary/10" />
<StatCard title="Total Checked In" value={stats.checkedIn} Icon={UserCheck} colorClass="text-present-700" bgClass="bg-present-100" />
<StatCard title="Total Not Checked In" value={stats.notCheckedIn} Icon={UserX} colorClass="text-absent-700" bgClass="bg-absent-100" />
</div>
{/* MUI Charts */}
<AttendanceCharts checkedIn={stats.checkedIn} notCheckedIn={stats.notCheckedIn} />
</div>
);
}

View File

@ -1,118 +0,0 @@
import { useState } from 'react';
import { FileText, Download, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import DailyReportFilters from '../components/summary/DailyReportFilters';
import DailyReportTable from '../components/summary/DailyReportTable';
import { useDailyReport } from '../api/useAttendanceData';
import { type DailyReportFilters as FilterValues } from '../types/attendance';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
export default function DailyReport() {
const [filters, setFilters] = useState<FilterValues>({
date: getTodayDate(),
companyId: '',
branchId: '',
departmentId: '',
designationId: '',
employeeName: '',
employeeCode: '',
});
const [downloadFormat, setDownloadFormat] = useState<'excel' | 'pdf'>('excel');
const roleState = useSelector((state: RootState) => state.role);
const { data: reportData, isLoading, isFetching } = useDailyReport(filters);
const handleDownload = async () => {
try {
const queryParams = new URLSearchParams({
date: filters.date,
company_id: filters.companyId,
branch_id: filters.branchId,
department_id: filters.departmentId,
designation: filters.designationId,
employee_name: filters.employeeName,
employee_code: filters.employeeCode,
file_type: downloadFormat,
}).toString();
const response = await fetch(`/api/ams/attendance/daily-report/export?${queryParams}`, {
headers: {
'X-Mock-User-Id': roleState.mockUserId,
'X-Mock-User-Role': roleState.mockUserRole,
'X-Mock-Branches': roleState.mockBranches
}
});
if (!response.ok) throw new Error('Failed to download report');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
const fileName = contentDisposition ? contentDisposition.split('filename=')[1].replace(/"/g, '') : `Daily_Report.${downloadFormat === 'excel' ? 'xlsx' : 'pdf'}`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Report downloaded successfully.');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
toast.error('Failed to download report.');
}
};
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<FileText className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Daily Attendance Report</h2>
<p className="text-sm text-text-muted">View daily attendance logs based on filters.</p>
</div>
</div>
<div className="flex items-center space-x-2">
<select
value={downloadFormat}
onChange={(e) => setDownloadFormat(e.target.value as 'excel' | 'pdf')}
className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card h-[38px]"
>
<option value="excel">Excel (.xlsx)</option>
<option value="pdf">PDF (.pdf)</option>
</select>
<button
onClick={handleDownload}
disabled={isLoading || isFetching}
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed h-[38px]"
>
{isLoading || isFetching ? <Loader2 size={18} className="mr-2 animate-spin" /> : <Download size={18} className="mr-2" />}
Download
</button>
</div>
</div>
<DailyReportFilters filters={filters} onFilterChange={setFilters} />
<DailyReportTable
records={reportData || []}
date={filters.date}
isLoading={isLoading || isFetching}
/>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { useState, useMemo } from 'react';
import Calendar from '../components/Calendar';
import AttendanceRecordsTable from '../components/AttendanceRecordsTable';
import DailySummary from '../components/DailySummary';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useAttendanceData } from '../api/useAttendanceData';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
export default function EmployeeAttendance() {
const [selectedDate, setSelectedDate] = useState(getTodayDate());
const { data: attendanceRecords, isLoading, isError } = useAttendanceData();
const calendarData = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const map: Record<string, any> = {};
if (attendanceRecords) {
attendanceRecords.forEach(record => {
map[record.date] = {
status: record.status, login: record.timeIn, logout: record.timeOut, hours: record.loggedHours
};
});
}
return map;
}, [attendanceRecords]);
// Use the global Loader component here
if (isLoading) return <Loader message="Loading attendance data..." />;
if (isError) return <ErrorState message="Failed to load attendance data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<Calendar data={calendarData} selectedDate={selectedDate} onDateSelect={setSelectedDate} />
</div>
<DailySummary selectedDate={selectedDate} data={calendarData[selectedDate] || null} />
</div>
{attendanceRecords && <AttendanceRecordsTable records={attendanceRecords} title="My Attendance Records" />}
</div>
);
}

View File

@ -1,42 +0,0 @@
import { Check } from 'lucide-react';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import RegularizationTable from '../components/summary/RegularizationTable';
import { toast } from 'sonner';
import { useHRManagerRegularizations, useReviewRegularization } from '../api/useAttendanceData';
export default function HRManagerRegularization() {
const { data: records, isLoading, isError } = useHRManagerRegularizations();
const { mutate } = useReviewRegularization();
const handleAction = (id: string, status: 'Approved' | 'Rejected') => {
mutate({ id, status }, {
onSuccess: () => {
toast.success(`Regularization request ${status.toLowerCase()} successfully.`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error?.response?.data?.error || "Failed to update regularization status.");
}
});
};
if (isLoading) return <Loader message="Loading Regularizations..." />;
if (isError) return <ErrorState message="Failed to load regularizations data." />;
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<Check className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Regularization Requests</h2>
<p className="text-sm text-text-muted">Review and approve pending requests.</p>
</div>
</div>
<RegularizationTable records={records || []} onAction={handleAction} />
</div>
);
}

View File

@ -0,0 +1,44 @@
import { useState, useMemo } from 'react';
import Calendar from '../components/Calendar';
import AttendanceRecordsTable from '../components/AttendanceRecordsTable';
import DailySummary from '../components/DailySummary';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useAttendanceData } from '../api/useAttendanceData';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
export default function ManagerAttendance() {
const [selectedDate, setSelectedDate] = useState(getTodayDate());
const { data: attendanceRecords, isLoading, isError } = useAttendanceData();
const calendarData = useMemo(() => {
const map: Record<string, any> = {};
if (attendanceRecords) {
attendanceRecords.forEach(record => {
map[record.date] = {
status: record.status, login: record.timeIn, logout: record.timeOut, hours: record.loggedHours
};
});
}
return map;
}, [attendanceRecords]);
if (isLoading) return <Loader message="Loading my attendance..." />;
if (isError) return <ErrorState message="Failed to load attendance data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<Calendar data={calendarData} selectedDate={selectedDate} onDateSelect={setSelectedDate} />
</div>
<DailySummary selectedDate={selectedDate} data={calendarData[selectedDate] || null} />
</div>
{attendanceRecords && <AttendanceRecordsTable records={attendanceRecords} title="My Attendance Records" />}
</div>
);
}

View File

@ -1,126 +0,0 @@
import { useState } from 'react';
import { Download, FileText, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import MonthlyReportFilters from '../components/summary/MonthlyReportFilters';
import MonthlyReportTable from '../components/summary/MonthlyReportTable';
import { type AdminReportFilters } from '../types/attendance';
import { useAdminReport } from '../api/useAttendanceData';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
const getMonthStart = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`;
};
export default function MonthlyReport() {
const [filters, setFilters] = useState<AdminReportFilters>({
fromDate: getMonthStart(),
toDate: getTodayDate(),
companyId: '',
branchId: '',
departmentId: '',
designation: '',
employeeName: '',
employeeCode: '',
});
const [downloadFormat, setDownloadFormat] = useState<'excel' | 'pdf'>('excel');
const roleState = useSelector((state: RootState) => state.role);
const { data: reportData, isLoading, isFetching } = useAdminReport(filters);
const handleDownload = async () => {
try {
const queryParams = new URLSearchParams({
from_date: filters.fromDate,
to_date: filters.toDate,
company_id: filters.companyId,
branch_id: filters.branchId,
department_id: filters.departmentId,
designation: filters.designation,
employee_name: filters.employeeName,
employee_code: filters.employeeCode,
file_type: downloadFormat,
}).toString();
const response = await fetch(`/api/ams/attendance/admin-report/export?${queryParams}`, {
headers: {
'X-Mock-User-Id': roleState.mockUserId,
'X-Mock-User-Role': roleState.mockUserRole,
'X-Mock-Branches': roleState.mockBranches
}
});
if (!response.ok) throw new Error('Failed to download report');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
const fileName = contentDisposition ? contentDisposition.split('filename=')[1].replace(/"/g, '') : `Monthly_Report.${downloadFormat === 'excel' ? 'xlsx' : 'pdf'}`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Report downloaded successfully.');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
toast.error('Failed to download report.');
}
};
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<FileText className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Monthly Attendance Report</h2>
<p className="text-sm text-text-muted">View monthly attendance metrics based on filters.</p>
</div>
</div>
<div className="flex items-center space-x-2">
<select
value={downloadFormat}
onChange={(e) => setDownloadFormat(e.target.value as 'excel' | 'pdf')}
className="px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card h-[38px]"
>
<option value="excel">Excel (.xlsx)</option>
<option value="pdf">PDF (.pdf)</option>
</select>
<button
onClick={handleDownload}
disabled={isLoading || isFetching}
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed h-[38px]"
>
{isLoading || isFetching ? <Loader2 size={18} className="mr-2 animate-spin" /> : <Download size={18} className="mr-2" />}
Download
</button>
</div>
</div>
<MonthlyReportFilters filters={filters} onFilterChange={setFilters} />
<MonthlyReportTable
records={reportData || []}
fromDate={filters.fromDate}
toDate={filters.toDate}
isLoading={isLoading || isFetching}
/>
</div>
);
}

View File

@ -1,94 +0,0 @@
import { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import Calendar from '../components/attendance/Calendar';
import AttendanceRecordsTable from '../components/attendance/AttendanceRecordsTable';
import DailySummary from '../components/attendance/DailySummary';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useEmployeeRangeReport } from '../api/useAttendanceData';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
const getMonthStart = (year: number, month: number) => `${year}-${String(month + 1).padStart(2, '0')}-01`;
const getMonthEnd = (year: number, month: number) => `${year}-${String(month + 1).padStart(2, '0')}-${String(new Date(year, month + 1, 0).getDate()).padStart(2, '0')}`;
export default function MyAttendance() {
const today = new Date();
// Dynamically get the logged-in user's ID from Redux
// This is the key to data isolation!
const employeeId = useSelector((state: RootState) => Number(state.role.mockUserId));
const [selectedDate, setSelectedDate] = useState(getTodayDate());
const [calendarMonth, setCalendarMonth] = useState(today.getMonth());
const [calendarYear, setCalendarYear] = useState(today.getFullYear());
const [tableFromDate, setTableFromDate] = useState(getMonthStart(today.getFullYear(), today.getMonth()));
const [tableToDate, setTableToDate] = useState(getTodayDate());
const calendarStartDate = getMonthStart(calendarYear, calendarMonth);
const calendarEndDate = getMonthEnd(calendarYear, calendarMonth);
// Pass the dynamically fetched employeeId to the API
const { data: calendarRecords, isLoading: calLoading, isError: calError } = useEmployeeRangeReport(employeeId, calendarStartDate, calendarEndDate);
const { data: tableRecords, isLoading: tableLoading } = useEmployeeRangeReport(employeeId, tableFromDate, tableToDate);
const calendarData = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const map: Record<string, any> = {};
if (calendarRecords) {
calendarRecords.forEach(record => {
map[record.date] = {
status: record.status,
login: record.checkIn,
logout: record.checkOut,
hours: record.totalHours,
attendanceId: record.attendanceId
};
});
}
return map;
}, [calendarRecords]);
if (calLoading && !calendarRecords) return <Loader message="Loading attendance data..." />;
if (calError) return <ErrorState message="Failed to load attendance data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<Calendar
data={calendarData}
selectedDate={selectedDate}
currentMonth={calendarMonth}
currentYear={calendarYear}
onDateSelect={setSelectedDate}
onMonthChange={(month, year) => {
setCalendarMonth(month);
setCalendarYear(year);
}}
/>
</div>
<DailySummary
selectedDate={selectedDate}
data={calendarData[selectedDate] || null}
employeeId={employeeId}
/>
</div>
<AttendanceRecordsTable
records={tableRecords || []}
title="Attendance Records"
fromDate={tableFromDate}
toDate={tableToDate}
onFromDateChange={setTableFromDate}
onToDateChange={setTableToDate}
isLoading={tableLoading}
/>
</div>
);
}

View File

@ -1,28 +0,0 @@
import { History } from 'lucide-react';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import RegularizationHistoryTable from '../components/summary/RegularizationHistoryTable';
import { useRegularizationHistory } from '../api/useAttendanceData';
export default function RegularizationHistory() {
const { data: records, isLoading, isError } = useRegularizationHistory();
if (isLoading) return <Loader message="Loading Regularization History..." />;
if (isError) return <ErrorState message="Failed to load regularization history." />;
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<History className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Regularization History</h2>
<p className="text-sm text-text-muted">View all past and pending regularization requests.</p>
</div>
</div>
<RegularizationHistoryTable records={records || []} />
</div>
);
}

View File

@ -0,0 +1,44 @@
import { useState, useMemo } from 'react';
import Calendar from '../components/Calendar';
import AttendanceRecordsTable from '../components/AttendanceRecordsTable';
import DailySummary from '../components/DailySummary';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useAttendanceData } from '../api/useAttendanceData';
const getTodayDate = () => {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
};
export default function SuperAdminAttendance() {
const [selectedDate, setSelectedDate] = useState(getTodayDate());
const { data: attendanceRecords, isLoading, isError } = useAttendanceData();
const calendarData = useMemo(() => {
const map: Record<string, any> = {};
if (attendanceRecords) {
attendanceRecords.forEach(record => {
map[record.date] = {
status: record.status, login: record.timeIn, logout: record.timeOut, hours: record.loggedHours
};
});
}
return map;
}, [attendanceRecords]);
if (isLoading) return <Loader message="Loading my attendance..." />;
if (isError) return <ErrorState message="Failed to load attendance data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<Calendar data={calendarData} selectedDate={selectedDate} onDateSelect={setSelectedDate} />
</div>
<DailySummary selectedDate={selectedDate} data={calendarData[selectedDate] || null} />
</div>
{attendanceRecords && <AttendanceRecordsTable records={attendanceRecords} title="My Attendance Records" />}
</div>
);
}

View File

@ -1,5 +1,18 @@
import MyAttendance from '../pages/MyAttendance';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import EmployeeAttendance from '../pages/EmployeeAttendance';
import ManagerAttendance from '../pages/ManagerAttendance';
import AdminAttendance from '../pages/AdminAttendance';
import SuperAdminAttendance from '../pages/SuperAdminAttendance';
export default function AttendanceRouter() {
return <MyAttendance />;
const role = useSelector((state: RootState) => state.role.currentRole);
switch (role) {
case 'employee': return <EmployeeAttendance />;
case 'manager': return <ManagerAttendance />;
case 'admin': return <AdminAttendance />;
case 'superadmin': return <SuperAdminAttendance />;
default: return <div>Unauthorized</div>;
}
}

View File

@ -1,19 +1,14 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import AttendanceDashboard from '../pages/AttendanceDashboard';
import MonthlyReport from '../pages/MonthlyReport';
import DailyReport from '../pages/DailyReport';
import HRManagerRegularization from '../pages/HRManagerRegularization';
import RegularizationHistory from '../pages/RegularizationHistory';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
const AdminAttendanceSummary = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Attendance Summary Report</h2><p className="mt-2 text-text-muted">Generate branch-wise attendance reports.</p></div>;
export default function AttendanceSummaryRouter() {
return (
<Routes>
<Route path="/" element={<Navigate to="/attendance-summary/dashboard" replace />} />
<Route path="/dashboard" element={<AttendanceDashboard />} />
<Route path="/monthly" element={<MonthlyReport />} />
<Route path="/daily" element={<DailyReport />} />
<Route path="/regularization" element={<HRManagerRegularization />} />
<Route path="/regularization-history" element={<RegularizationHistory />} />
</Routes>
);
const role = useSelector((state: RootState) => state.role.currentRole);
if (role === 'admin' || role === 'superadmin') {
return <AdminAttendanceSummary />;
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: Admins only.</div>;
}

View File

@ -1,110 +0,0 @@
export type AttendanceStatus = 'Present' | 'Absent' | 'Leave' | 'Mispunch' | 'Holiday' | 'Half Day';
export interface AttendanceRecord {
date: string;
checkIn: string;
checkOut: string;
totalHours: string;
status: AttendanceStatus;
attendanceId: number;
}
export type SortKey = keyof AttendanceRecord;
export interface AttendanceMetrics {
totalDaysTracked: number;
totalHoursWorked: number;
fullDays: number;
halfDays: number;
absences: number;
mispunches: number;
lateArrivals: number;
}
export interface AttendanceSummary {
metrics: AttendanceMetrics;
history: AttendanceRecord[];
}
export interface DashboardMetrics {
total_employees: number;
total_checked_in: number;
total_not_checked_in: number;
}
export interface DailyReportRecord {
employeeId: number;
employeeCode: string;
fullName: string;
status: string;
checkIn: string;
checkOut: string;
workedHours: string;
companyName?: string;
branchName?: string;
departmentName?: string;
designation?: string;
}
export interface AdminReportRecord {
employeeId: number;
employeeCode: string;
firstName: string;
lastName: string;
companyName: string;
branchName: string;
departmentName: string;
designation: string;
rangeMetrics: {
fullDays: number;
halfDays: number;
mispunches: number;
lateArrivals: number;
};
}
export interface DailyReportFilters {
date: string;
companyId: string;
branchId: string;
departmentId: string;
designationId: string;
employeeName: string;
employeeCode: string;
}
export interface AdminReportFilters {
fromDate: string;
toDate: string;
companyId: string;
branchId: string;
departmentId: string;
designation: string;
employeeName: string;
employeeCode: string;
}
export interface RegularizationPayload {
attendance_id: number;
employee_id: number;
regularization_type: string;
target_date: string;
requested_check_in: string;
requested_check_out: string;
reason: string;
}
export interface RegularizationRecord {
id: string;
empId: string;
empName: string;
date: string;
regularizationType: string;
reqCheckIn: string;
reqCheckOut: string;
reason: string;
status: 'Pending' | 'Approved' | 'Rejected';
branchName: string;
reviewerName?: string;
}

View File

@ -0,0 +1,27 @@
export default function AdminDashboard() {
return (
<div className="bg-app-card rounded-lg shadow-sm p-6">
<h2 className="text-2xl font-bold text-primary">Admin / SuperAdmin Dashboard</h2>
<p className="text-text-muted mt-2">Global branch analytics and summaries.</p>
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="p-4 border rounded-lg bg-app-muted">
<h3 className="font-semibold text-sm text-text-muted">Total Employees</h3>
<p className="text-2xl text-primary">245</p>
</div>
<div className="p-4 border rounded-lg bg-app-muted">
<h3 className="font-semibold text-sm text-text-muted">Present Today</h3>
<p className="text-2xl text-present-600">198</p>
</div>
<div className="p-4 border rounded-lg bg-app-muted">
<h3 className="font-semibold text-sm text-text-muted">On Leave</h3>
<p className="text-2xl text-action">12</p>
</div>
<div className="p-4 border rounded-lg bg-app-muted">
<h3 className="font-semibold text-sm text-text-muted">Mispunch</h3>
<p className="text-2xl text-absent-500">5</p>
</div>
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
export default function DirectorDashboard() {
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<h2 className="text-2xl font-bold text-text-primary">Welcome back, Director!</h2>
</div>
</div>
);
}

View File

@ -1,8 +1,18 @@
export default function EmployeeDashboard() {
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<h2 className="text-2xl font-bold text-text-primary">Welcome back, Employee!</h2>
<div className="bg-app-card rounded-lg shadow-sm p-6">
<h2 className="text-2xl font-bold text-primary">Employee Dashboard</h2>
<p className="text-text-muted mt-2">Welcome! Here you can mark your attendance and check your leave balance.</p>
<div className="mt-6 grid grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<h3 className="font-semibold">Leave Balance</h3>
<p className="text-2xl text-action">12 Days</p>
</div>
<div className="p-4 border rounded-lg">
<h3 className="font-semibold">Attendance Status</h3>
<button className="mt-2 px-4 py-1 bg-action text-white rounded">Check In</button>
</div>
</div>
</div>
);

View File

@ -1,9 +0,0 @@
export default function HRManagerDashboard() {
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<h2 className="text-2xl font-bold text-text-primary">Welcome back, HRManager!</h2>
</div>
</div>
);
}

View File

@ -1,8 +1,21 @@
export default function ManagerDashboard() {
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<h2 className="text-2xl font-bold text-text-primary">Welcome back, Manager!</h2>
<div className="bg-app-card rounded-lg shadow-sm p-6">
<h2 className="text-2xl font-bold text-primary">Manager Dashboard</h2>
<p className="text-text-muted mt-2">Welcome Manager! Review your team's pending requests below.</p>
<div className="mt-6">
<h3 className="font-semibold text-lg">Pending Approvals</h3>
<ul className="mt-2 space-y-2">
<li className="p-3 border rounded flex justify-between items-center">
<span>John Doe - Casual Leave (2 Days)</span>
<button className="px-3 py-1 bg-primary text-white rounded text-sm">Review</button>
</li>
<li className="p-3 border rounded flex justify-between items-center">
<span>Jane Smith - Late Regularization</span>
<button className="px-3 py-1 bg-primary text-white rounded text-sm">Review</button>
</li>
</ul>
</div>
</div>
);

View File

@ -2,8 +2,7 @@ import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import EmployeeDashboard from '../pages/EmployeeDashboard';
import ManagerDashboard from '../pages/ManagerDashboard';
import HRManagerDashboard from '../pages/HRManagerDashboard';
import DirectorDashboard from '../pages/DirectorDashboard';
import AdminDashboard from '../pages/AdminDashboard';
export default function DashboardRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
@ -13,10 +12,9 @@ export default function DashboardRouter() {
return <EmployeeDashboard />;
case 'manager':
return <ManagerDashboard />;
case 'hrmanager':
return <HRManagerDashboard />;
case 'director':
return <DirectorDashboard />;
case 'admin':
case 'superadmin':
return <AdminDashboard />;
default:
return <div>No role assigned</div>;
}

View File

@ -0,0 +1,17 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
export default function ManageAdminsRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
if (role === 'superadmin') {
return (
<div className="bg-app-card p-6 rounded-lg shadow">
<h2 className="text-2xl font-bold text-primary">Manage Admins</h2>
<p className="mt-2 text-text-muted">Create and manage branch administrators and their permissions.</p>
</div>
);
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: SuperAdmins only.</div>;
}

View File

@ -0,0 +1,17 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
export default function ManageEmployeesRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
if (role === 'admin' || role === 'superadmin') {
return (
<div className="bg-app-card p-6 rounded-lg shadow">
<h2 className="text-2xl font-bold text-primary">Manage Employees</h2>
<p className="mt-2 text-text-muted">Add, edit, or remove employee records and manage branch assignments.</p>
</div>
);
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: Admins only.</div>;
}

View File

@ -0,0 +1,17 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
export default function PolicyRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
if (role === 'superadmin') {
return (
<div className="bg-app-card p-6 rounded-lg shadow">
<h2 className="text-2xl font-bold text-primary">Policy and Rules</h2>
<p className="mt-2 text-text-muted">Configure global leave policies, LOP rules, and attendance shift timings.</p>
</div>
);
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: SuperAdmins only.</div>;
}

View File

@ -1,278 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import { apiClient } from '../../../api/client';
import type { RootState } from '../../../store/store';
import type {
LeaveSummary,
LeaveRequestPayload,
LeaveHistoryRecord,
ManagerLeaveApproval,
ApprovalStatus,
LeaveApplicationSummary,
LeaveBalanceRecord
} from '../types/leave';
import { useEmployeeById } from '../../masterData/api/useEmployeeData';
export const useCreateLeaveType = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { company_id: number; name: string; requires_allocation: boolean; carry_over_allowed: boolean; max_carry_over_days: number }) => {
const response = await apiClient.post('/api/lms/config/types', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leaveTypes'] });
},
});
};
export const useLeaveTypes = (companyId: number = 1) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useQuery<any[]>({
queryKey: ['leaveTypes', companyId],
queryFn: async () => {
const response = await apiClient.get(`/api/lms/config/types?company_id=${companyId}`);
return response.data.data || [];
},
});
};
export const useCreatePolicyRule = () => {
const queryClient = useQueryClient();
return useMutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: async (data: any) => {
const response = await apiClient.post('/api/lms/config/rules', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['policyRules'] });
},
});
};
export const useCreateCompanyHoliday = () => {
const queryClient = useQueryClient();
return useMutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: async (holidays: any[]) => {
const response = await apiClient.post('/api/lms/config/holidays', holidays);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['holidays'] });
},
});
};
export const useUpdateWorkSettings = () => {
return useMutation({
mutationFn: async (data: { company_id: number; branch_id: number; weekly_off_days: number[]; effective_from: string }) => {
const response = await apiClient.put('/api/lms/config/work-settings', data);
return response.data;
},
});
};
export const useLeaveSummary = () => {
return useQuery<LeaveSummary[]>({
queryKey: ['leaveSummary'],
queryFn: async () => {
const response = await apiClient.get('/api/lms/leaves/balances', {
params: { year: new Date().getFullYear() }
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response.data.balances || []).map((b: any) => ({
leaveTypeId: b.leaveTypeId,
leaveType: b.leaveTypeName,
credited: b.grantedDays,
utilized: b.usedDays,
lapsed: 0,
balance: b.availableBalance
}));
},
});
};
export const useApplyLeave = () => {
const queryClient = useQueryClient();
const employeeId = useSelector((state: RootState) => Number(state.role.mockUserId));
// Dynamically fetch the logged-in user's profile to get companyId and branchId
const { data: empDetail } = useEmployeeById(employeeId);
return useMutation({
mutationFn: async (payload: LeaveRequestPayload) => {
if (!empDetail) {
throw new Error("User profile is still loading. Please try again in a moment.");
}
const reqPayload = {
employee_id: employeeId,
leave_type_id: payload.leaveTypeId,
company_id: empDetail.companyId,
branch_id: empDetail.branchId,
date_from: payload.fromDate,
date_to: payload.toDate,
is_half_day: payload.leaveDays === 0.5,
reason: payload.reason,
};
const response = await apiClient.post('/api/lms/leaves/apply', reqPayload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leaveSummary'] });
queryClient.invalidateQueries({ queryKey: ['leaveHistory'] });
},
});
};
export const useLeaveHistory = () => {
return useQuery<LeaveHistoryRecord[]>({
queryKey: ['leaveHistory'],
queryFn: async () => {
const response = await apiClient.get('/api/lms/leaves/history', {
params: { year: new Date().getFullYear() }
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response.data.history || []).map((h: any) => ({
id: String(h.applicationId),
leaveType: h.leaveTypeName,
applicationDate: new Date().toISOString().split('T')[0],
fromDate: h.dateFrom,
toDate: h.dateTo,
leaveDays: h.numberOfDays,
status: h.status,
reason: h.reason
}));
},
});
};
export const useValidOptionalHolidays = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useQuery<any[]>({
queryKey: ['optionalHolidays'],
queryFn: async () => {
const response = await apiClient.get('/api/lms/holidays/valid-optional');
return response.data.holidays || [];
},
});
};
export const usePendingApprovals = () => {
return useQuery<ManagerLeaveApproval[]>({
queryKey: ['pendingApprovals'],
queryFn: async () => {
const response = await apiClient.get('/api/lms/manager/pending');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response.data.data || []).map((p: any) => ({
id: String(p.applicationId),
employeeId: p.employeeCode || String(p.employeeId),
employeeName: `${p.firstName} ${p.lastName}`.trim(),
leaveType: p.leaveType,
applicationDate: new Date().toISOString().split('T')[0],
fromDate: p.dateFrom,
toDate: p.dateTo,
leaveDays: p.numberOfDays,
status: p.status,
reason: p.reason
}));
},
});
};
export const useManagerApprovals = usePendingApprovals;
export const useHRManagerApprovals = usePendingApprovals;
export const useDirectorApprovals = usePendingApprovals;
export const useUpdateLeaveApproval = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, status }: { id: string; status: ApprovalStatus }) => {
const endpoint = status === 'Approved' ? 'approve' : 'reject';
const payload = status === 'Rejected' ? { reason: "Rejected by approver" } : {};
const response = await apiClient.post(`/api/lms/manager/leaves/${id}/${endpoint}`, payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pendingApprovals'] });
},
});
};
// Get Ledger Report (OB-CB)
export const useLeaveBalance = (params: { month: number; year: number; companyId: number }, enabled: boolean) => {
return useQuery<LeaveBalanceRecord[]>({
queryKey: ['leaveBalance', params],
queryFn: async () => {
const response = await apiClient.get('/api/lms/admin/reports/ob-cb', {
params: { month: params.month, year: params.year, company_id: params.companyId }
});
const rawData = response.data.data || [];
const employeeMap: Record<string, LeaveBalanceRecord> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rawData.forEach((item: any) => {
if (!employeeMap[item.employeeId]) {
employeeMap[item.employeeId] = {
employeeId: item.employeeCode || String(item.employeeId),
employeeName: item.employeeName,
casualOpening: 0,
casualClosing: 0,
optionalOpening: 0,
optionalClosing: 0,
};
}
// Assign values based on leave type
if (item.leaveType === 'Casual Leave') {
employeeMap[item.employeeId].casualOpening = item.openingBalance;
employeeMap[item.employeeId].casualClosing = item.closingBalance;
} else if (item.leaveType === 'Optional Leave') {
employeeMap[item.employeeId].optionalOpening = item.openingBalance;
employeeMap[item.employeeId].optionalClosing = item.closingBalance;
}
});
return Object.values(employeeMap);
},
enabled: enabled, // Only fetch when the user clicks search
});
};
export const useLeaveApplications = () => {
return useQuery<LeaveApplicationSummary[]>({
queryKey: ['leaveApplications'],
queryFn: async () => {
const response = await apiClient.get('/api/lms/hr/applications');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response.data.data || []).map((p: any) => ({
id: String(p.applicationId),
employeeId: p.employeeCode,
employeeName: `${p.firstName} ${p.lastName}`.trim(),
jobRole: p.jobTitle,
department: p.departmentName,
managerName: p.managerName,
leaveType: p.leaveType,
fromDate: p.dateFrom,
toDate: p.dateTo,
leaveDays: p.numberOfDays,
status: p.status,
reason: p.reason,
}));
},
});
};

View File

@ -1,101 +0,0 @@
import { X, Check, Ban } from 'lucide-react';
import type { ManagerLeaveApproval, ApprovalStatus } from '../../types/leave';
interface ApprovalModalProps {
record: ManagerLeaveApproval;
onClose: () => void;
onAction: (id: string, status: ApprovalStatus) => void;
}
// Updated to handle backend uppercase statuses
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
return 'bg-app-muted text-text-secondary';
};
export default function ApprovalModal({ record, onClose, onAction }: ApprovalModalProps) {
const formatReadableDate = (dateStr: string) => {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' });
};
// Check if the status is pending
const isPending = record.status.toUpperCase().includes('PENDING');
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">Leave Application Details</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Application ID</span>
<span className="text-sm font-medium text-text-primary">{record.id}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Employee ID</span>
<span className="text-sm font-medium text-text-primary">{record.employeeId}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Employee Name</span>
<span className="text-sm font-medium text-text-primary">{record.employeeName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Leave Type</span>
<span className="text-sm font-medium text-text-primary">{record.leaveType}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Application Date</span>
<span className="text-sm font-medium text-text-primary">{formatReadableDate(record.applicationDate)}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Duration</span>
<span className="text-sm font-medium text-text-primary text-right">
{formatReadableDate(record.fromDate)} to {formatReadableDate(record.toDate)}<br />
<span className="text-text-muted">({record.leaveDays} Days)</span>
</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Current Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</div>
<div>
<span className="text-sm text-text-muted block mb-1">Reason</span>
<p className="text-sm text-text-secondary bg-app-muted p-3 rounded-lg">{record.reason}</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
{/* Render buttons if the status is PENDING */}
{isPending ? (
<>
<button
onClick={() => onAction(record.id, 'Rejected')}
className="flex items-center px-4 py-2 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm font-medium"
>
<Ban size={16} className="mr-2" /> Reject
</button>
<button
onClick={() => onAction(record.id, 'Approved')}
className="flex items-center px-4 py-2 bg-present-600 text-white rounded-lg hover:bg-present-700 transition-colors text-sm font-medium"
>
<Check size={16} className="mr-2" /> Approve
</button>
</>
) : (
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -1,192 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Eye, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { ManagerLeaveApproval, ApprovalStatus } from '../../types/leave';
import ApprovalModal from './ApprovalModal';
interface ApprovalTableProps {
records: ManagerLeaveApproval[];
onAction: (id: string, status: ApprovalStatus) => void;
}
type SortKey = 'employeeId' | 'employeeName' | 'leaveType' | 'applicationDate' | 'leaveDays' | 'status';
type SortDirection = 'ascending' | 'descending';
// Updated to handle backend uppercase statuses
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
return 'bg-app-muted text-text-secondary';
};
export default function ApprovalTable({ records, onAction }: ApprovalTableProps) {
const [viewRecord, setViewRecord] = useState<ManagerLeaveApproval | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'applicationDate',
direction: 'descending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.employeeId.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.employeeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.leaveType.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm mt-6">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Pending Leave Approvals</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search name, type, status..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['employeeId', 'employeeName', 'leaveType', 'applicationDate', 'leaveDays', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.employeeId}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.employeeName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.leaveType}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{new Date(record.applicationDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-primary">{record.leaveDays}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button onClick={() => setViewRecord(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-sm text-text-muted">No matching leave requests found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
{viewRecord && (
<ApprovalModal
record={viewRecord}
onClose={() => setViewRecord(null)}
onAction={(id, status) => {
onAction(id, status);
setViewRecord(null);
}}
/>
)}
</div>
);
}

View File

@ -1,76 +0,0 @@
import { Search } from 'lucide-react';
export interface BalanceFilterValues {
companyId: string;
month: number;
year: number;
}
interface BalanceFiltersProps {
filters: BalanceFilterValues;
companies: { id: number; name: string }[];
onFilterChange: (newFilters: BalanceFilterValues) => void;
onSearch: (e: React.FormEvent) => void;
}
const monthsArray = [
{ value: 1, label: 'January' }, { value: 2, label: 'February' }, { value: 3, label: 'March' },
{ value: 4, label: 'April' }, { value: 5, label: 'May' }, { value: 6, label: 'June' },
{ value: 7, label: 'July' }, { value: 8, label: 'August' }, { value: 9, label: 'September' },
{ value: 10, label: 'October' }, { value: 11, label: 'November' }, { value: 12, label: 'December' }
];
const currentYear = new Date().getFullYear();
const yearsArray = Array.from({ length: 5 }, (_, i) => currentYear - 2 + i);
export default function BalanceFilters({ filters, companies, onFilterChange, onSearch }: BalanceFiltersProps) {
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card";
const labelClass = "text-xs text-text-muted mb-1";
return (
<form onSubmit={onSearch} className="bg-app-card p-4 rounded-lg shadow-sm grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
<div className="flex flex-col">
<label className={labelClass}>Company Name</label>
<select
value={filters.companyId}
onChange={(e) => onFilterChange({ ...filters, companyId: e.target.value })}
className={inputClass}
required
>
<option value="">Select Company</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Month</label>
<select
value={filters.month}
onChange={(e) => onFilterChange({ ...filters, month: Number(e.target.value) })}
className={inputClass}
>
{monthsArray.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
<div className="flex flex-col">
<label className={labelClass}>Year</label>
<select
value={filters.year}
onChange={(e) => onFilterChange({ ...filters, year: Number(e.target.value) })}
className={inputClass}
>
{yearsArray.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<button
type="submit"
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium h-[38px]"
>
<Search size={18} className="mr-2" />
Search
</button>
</form>
);
}

View File

@ -1,65 +0,0 @@
import type { LeaveBalanceRecord } from '../../../types/leave';
interface BalanceTableProps {
records: LeaveBalanceRecord[];
isSearched: boolean;
month: number;
year: number;
}
export default function BalanceTable({ records, isSearched, month, year }: BalanceTableProps) {
const formatMonthYear = () => {
const monthName = new Date(year, month - 1).toLocaleString('default', { month: 'long' });
return `${monthName} ${year}`;
};
if (!isSearched) return null;
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm overflow-x-auto">
<h3 className="text-lg font-semibold text-text-primary mb-4">
Search Results ({records.length})
</h3>
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Emp CODE</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Employee Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">
Casual Opening ({formatMonthYear()})
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">
Casual Closing ({formatMonthYear()})
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">
Optional Opening ({formatMonthYear()})
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">
Optional Closing ({formatMonthYear()})
</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{records.length > 0 ? (
records.map((record, index) => (
<tr key={index} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-3 text-sm text-text-primary">{record.employeeId}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.employeeName}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.casualOpening}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.casualClosing}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.optionalOpening}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{record.optionalClosing}</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-text-muted">
No Leave Balance records found for the selected filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@ -1,143 +0,0 @@
import { useState, useMemo } from 'react';
import { toast } from 'sonner';
import { useApplyLeave } from '../../../api/useLeaveData';
import type { LeaveSummary, LeaveRequestPayload, DayMode } from '../../../types/leave';
interface ApplyLeaveFormProps {
summaryData: LeaveSummary[];
}
const calculateLeaveDays = (from: string, fromMode: DayMode, to: string, toMode: DayMode) => {
if (!from || !to) return 0;
const start = new Date(from + 'T00:00:00');
const end = new Date(to + 'T00:00:00');
if (end < start) return 0;
const diffTime = Math.abs(end.getTime() - start.getTime());
let days = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
if (fromMode !== 'Full Day') days -= 0.5;
if (toMode !== 'Full Day') days -= 0.5;
return days;
};
const calculateResumptionDate = (to: string) => {
if (!to) return '';
const date = new Date(to + 'T00:00:00');
date.setDate(date.getDate() + 1);
if (date.getDay() === 0) date.setDate(date.getDate() + 1);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function ApplyLeaveForm({ summaryData }: ApplyLeaveFormProps) {
const { mutate, isPending } = useApplyLeave();
const [showForm, setShowForm] = useState(false);
// Track both ID (for API) and Name (for UI display)
const [selectedLeaveTypeId, setSelectedLeaveTypeId] = useState<number | ''>('');
const [selectedLeaveTypeName, setSelectedLeaveTypeName] = useState('');
const [formData, setFormData] = useState<LeaveRequestPayload>({
leaveTypeId: 0, leaveType: '', fromDate: '', fromDayMode: 'Full Day', toDate: '', toDayMode: 'Full Day', leaveDays: 0, resumptionDate: '', reason: '',
});
const calculatedDays = useMemo(() => calculateLeaveDays(formData.fromDate, formData.fromDayMode, formData.toDate, formData.toDayMode), [formData.fromDate, formData.fromDayMode, formData.toDate, formData.toDayMode]);
const calculatedResumption = useMemo(() => calculateResumptionDate(formData.toDate), [formData.toDate]);
const handleProceed = () => {
if (!selectedLeaveTypeId) { toast.error("Please select a leave type first."); return; }
setShowForm(true);
setFormData(prev => ({ ...prev, leaveTypeId: Number(selectedLeaveTypeId), leaveType: selectedLeaveTypeName }));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (calculatedDays <= 0) { toast.error("Invalid date range selected."); return; }
mutate({ ...formData, leaveDays: calculatedDays, resumptionDate: calculatedResumption }, {
onSuccess: () => {
toast.success("Leave applied successfully!");
setShowForm(false);
setSelectedLeaveTypeId('');
setSelectedLeaveTypeName('');
setFormData({ leaveTypeId: 0, leaveType: '', fromDate: '', fromDayMode: 'Full Day', toDate: '', toDayMode: 'Full Day', leaveDays: 0, resumptionDate: '', reason: '' });
},
onError: () => toast.error("Failed to apply leave."),
});
};
return (
<div className="w-full lg:w-96 bg-app-card p-6 rounded-lg shadow-sm h-fit">
<h2 className="text-lg font-semibold text-text-primary mb-4">Apply Leave</h2>
{!showForm ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Leave Type</label>
<select
value={selectedLeaveTypeId}
onChange={(e) => {
const id = Number(e.target.value);
setSelectedLeaveTypeId(id);
const selected = summaryData.find(l => l.leaveTypeId === id);
setSelectedLeaveTypeName(selected?.leaveType || '');
}}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
<option value="">Select Leave Type</option>
{summaryData.map((leave) => (
<option key={leave.leaveTypeId} value={leave.leaveTypeId}>{leave.leaveType}</option>
))}
</select>
</div>
<button onClick={handleProceed} className="w-full px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium">Proceed to Apply Leave</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">{selectedLeaveTypeName}</span>
<button type="button" onClick={() => setShowForm(false)} className="flex items-center px-4 py-1 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm ">Cancel</button>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">From Date</label>
<div className="flex space-x-2">
<input type="date" name="fromDate" value={formData.fromDate} onChange={handleChange} required className="flex-1 px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
<select name="fromDayMode" value={formData.fromDayMode} onChange={handleChange} className="px-2 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option>Full Day</option><option>First Half</option><option>Second Half</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">To Date</label>
<div className="flex space-x-2">
<input type="date" name="toDate" value={formData.toDate} onChange={handleChange} required className="flex-1 px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
<select name="toDayMode" value={formData.toDayMode} onChange={handleChange} className="px-2 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option>Full Day</option><option>First Half</option><option>Second Half</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-app-muted p-3 rounded-lg">
<span className="block text-xs text-text-muted">Leave Days</span>
<span className="text-sm font-bold text-text-primary">{calculatedDays} Days</span>
</div>
<div className="bg-app-muted p-3 rounded-lg">
<span className="block text-xs text-text-muted">Resumption Date</span>
<span className="text-sm font-bold text-text-primary">{calculatedResumption || '-'}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Reason</label>
<textarea name="reason" value={formData.reason} onChange={handleChange} required rows={3} className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card resize-none" placeholder="Brief reason for leave..."></textarea>
</div>
<button type="submit" disabled={isPending || calculatedDays <= 0} className="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center">
{isPending ? 'Applying...' : 'Apply Leave'}
</button>
</form>
)}
</div>
);
}

View File

@ -1,86 +0,0 @@
import { X } from 'lucide-react';
import type { LeaveApplicationSummary } from '../../../types/leave';
interface LeaveApplicationModalProps {
record: LeaveApplicationSummary;
onClose: () => void;
}
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
if (normalizedStatus.includes('WITHDRAWN') || normalizedStatus.includes('CANCELLED')) return 'bg-app-muted text-text-secondary';
return 'bg-app-muted text-text-secondary';
};
const formatReadableDate = (dateStr: string) => {
if (!dateStr) return '-';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' });
};
export default function LeaveApplicationModal({ record, onClose }: LeaveApplicationModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative max-h-[90vh] overflow-y-auto">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">Leave Application Details</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Application ID</span>
<span className="text-sm font-medium text-text-primary">{record.id}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Employee ID</span>
<span className="text-sm font-medium text-text-primary">{record.employeeId}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Employee Name</span>
<span className="text-sm font-medium text-text-primary">{record.employeeName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Job Role</span>
<span className="text-sm font-medium text-text-primary">{record.jobRole}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Department</span>
<span className="text-sm font-medium text-text-primary">{record.department}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Manager Name</span>
<span className="text-sm font-medium text-text-primary">{record.managerName}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Leave Type</span>
<span className="text-sm font-medium text-text-primary">{record.leaveType}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Duration</span>
<span className="text-sm font-medium text-text-primary text-right">
{formatReadableDate(record.fromDate)} to {formatReadableDate(record.toDate)}<br />
<span className="text-text-muted">({record.leaveDays} Days)</span>
</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</div>
<div className="border-b border-app-border pb-2">
<span className="text-sm text-text-muted block mb-1">Reason</span>
<p className="text-sm text-text-secondary bg-app-muted p-3 rounded-lg">{record.reason || 'No reason provided.'}</p>
</div>
</div>
<div className="flex justify-end mt-6">
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
</div>
</div>
</div>
);
}

View File

@ -1,69 +0,0 @@
import { X } from 'lucide-react';
import type { LeaveHistoryRecord } from '../../../types/leave';
interface ViewLeaveModalProps {
record: LeaveHistoryRecord;
onClose: () => void;
onWithdraw: (record: LeaveHistoryRecord) => void;
}
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
if (normalizedStatus.includes('WITHDRAWN') || normalizedStatus.includes('CANCELLED')) return 'bg-app-muted text-text-secondary';
return 'bg-app-muted text-text-secondary';
};
export default function ViewLeaveModal({ record, onClose, onWithdraw }: ViewLeaveModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">Leave Details</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Application ID</span>
<span className="text-sm font-medium text-text-primary">{record.id}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Leave Type</span>
<span className="text-sm font-medium text-text-primary">{record.leaveType}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Application Date</span>
<span className="text-sm font-medium text-text-primary">{new Date(record.applicationDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'long', year: 'numeric' })}</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Duration</span>
<span className="text-sm font-medium text-text-primary">{new Date(record.fromDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short' })} to {new Date(record.toDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })} ({record.leaveDays} Days)</span>
</div>
<div className="flex justify-between border-b border-app-border pb-2">
<span className="text-sm text-text-muted">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</div>
<div>
<span className="text-sm text-text-muted block mb-1">Reason</span>
<p className="text-sm text-text-secondary bg-app-muted p-3 rounded-lg">{record.reason}</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
{record.status.toUpperCase() === 'PENDING' && (
<button onClick={() => onWithdraw(record)} className="px-4 py-2 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm font-medium">
Withdraw Application
</button>
)}
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
</div>
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { AlertTriangle } from 'lucide-react';
import type { LeaveHistoryRecord } from '../../../types/leave';
interface WithdrawLeaveModalProps {
record: LeaveHistoryRecord;
onClose: () => void;
onConfirm: (id: string) => void;
}
export default function WithdrawLeaveModal({ record, onClose, onConfirm }: WithdrawLeaveModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60] p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-sm p-6 relative text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-absent-100 mb-4">
<AlertTriangle className="h-6 w-6 text-absent-500" />
</div>
<h3 className="text-lg font-medium text-text-primary mb-2">Withdraw Leave Application?</h3>
<p className="text-sm text-text-muted mb-6">
Are you sure you want to withdraw your <span className="font-medium text-text-primary">{record.leaveType}</span> application from <span className="font-medium text-text-primary">{new Date(record.fromDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short' })}</span> to <span className="font-medium text-text-primary">{new Date(record.toDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short' })}</span>? This action cannot be undone.
</p>
<div className="flex justify-center space-x-3">
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button onClick={() => onConfirm(record.id)} className="px-4 py-2 bg-absent-500 text-white rounded-lg hover:bg-absent-600 transition-colors text-sm font-medium">
Yes, Withdraw
</button>
</div>
</div>
</div>
);
}

View File

@ -1,200 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Eye, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { LeaveApplicationSummary } from '../../../types/leave';
import LeaveApplicationModal from '../modal/LeaveApplicationModal';
interface LeaveApplicationsTableProps {
records: LeaveApplicationSummary[];
}
type SortKey = 'id' | 'employeeId' | 'employeeName' | 'jobRole' | 'department' | 'managerName' | 'leaveType' | 'leaveDays' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
if (normalizedStatus.includes('WITHDRAWN') || normalizedStatus.includes('CANCELLED')) return 'bg-app-muted text-text-secondary';
return 'bg-app-muted text-text-secondary';
};
export default function LeaveApplicationsTable({ records }: LeaveApplicationsTableProps) {
const [viewRecord, setViewRecord] = useState<LeaveApplicationSummary | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'employeeName', direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.employeeId.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.employeeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.jobRole.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.managerName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.leaveType.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const formatHeader = (key: string) => {
if (key === 'id') return 'App ID';
if (key === 'employeeId') return 'Emp CODE';
if (key === 'employeeName') return 'Employee Name';
if (key === 'jobRole') return 'Job Role';
if (key === 'managerName') return 'Manager Name';
if (key === 'leaveType') return 'Leave Type';
if (key === 'leaveDays') return 'Leave Days';
return key.charAt(0).toUpperCase() + key.slice(1);
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Leave Applications</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search id, name, role..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['id', 'employeeId', 'employeeName', 'jobRole', 'department', 'managerName', 'leaveType', 'leaveDays', 'status'] as SortKey[]).map((key) => (
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
<div className="flex items-center">
{formatHeader(key)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.employeeId}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.employeeName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.jobRole}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.department}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.managerName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.leaveType}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.leaveDays}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button onClick={() => setViewRecord(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-6 py-8 text-center text-sm text-text-muted">No leave applications found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
{viewRecord && (
<LeaveApplicationModal
record={viewRecord}
onClose={() => setViewRecord(null)}
/>
)}
</div>
);
}

View File

@ -1,261 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye, Ban } from 'lucide-react';
import { toast } from 'sonner';
import type { LeaveHistoryRecord, LeaveSortKey } from '../../../types/leave';
import ViewLeaveModal from '../modal/ViewLeaveModal';
import WithdrawLeaveModal from '../modal/WithdrawLeaveModal';
interface LeaveHistoryTableProps {
records: LeaveHistoryRecord[];
}
type SortDirection = 'ascending' | 'descending';
const getDaysDifference = (start: string, end: string) => {
if (!start || !end) return 0;
const startDate = new Date(start + 'T00:00:00');
const endDate = new Date(end + 'T00:00:00');
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
};
export default function LeaveHistoryTable({ records }: LeaveHistoryTableProps) {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [sortConfig, setSortConfig] = useState<{ key: LeaveSortKey; direction: SortDirection }>({
key: 'applicationDate',
direction: 'descending'
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const [viewRecord, setViewRecord] = useState<LeaveHistoryRecord | null>(null);
const [withdrawRecord, setWithdrawRecord] = useState<LeaveHistoryRecord | null>(null);
const [localOverrides, setLocalOverrides] = useState<Record<string, LeaveHistoryRecord>>({});
const combinedRecords = useMemo(() => {
return records.map(r => localOverrides[r.id] || r);
}, [records, localOverrides]);
const filteredData = useMemo(() => {
return combinedRecords.filter((record) => {
// Filter based on the actual Leave Dates (fromDate and toDate)
// If a filter date is selected, check if the leave period overlaps with the selected range
const matchesFrom = fromDate ? record.toDate >= fromDate : true;
const matchesTo = toDate ? record.fromDate <= toDate : true;
return matchesFrom && matchesTo;
});
}, [combinedRecords, fromDate, toDate]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
// Handle sorting for dates properly
sortableData.sort((a, b) => {
const valA = a[sortConfig.key];
const valB = b[sortConfig.key];
// If sorting by dates, convert to Date objects for accurate comparison
if (['applicationDate', 'fromDate', 'toDate'].includes(sortConfig.key)) {
const dateA = new Date(valA + 'T00:00:00').getTime();
const dateB = new Date(valB + 'T00:00:00').getTime();
if (dateA < dateB) return sortConfig.direction === 'ascending' ? -1 : 1;
if (dateA > dateB) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
}
// Default string/number comparison
if (valA < valB) return sortConfig.direction === 'ascending' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const daysDifference = getDaysDifference(fromDate, toDate);
const isPaginationEnabled = daysDifference > 7 || sortedData.length > pageSize;
const totalPages = isPaginationEnabled ? Math.ceil(sortedData.length / pageSize) : 1;
const paginatedData = useMemo(() => {
if (!isPaginationEnabled) return sortedData;
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, isPaginationEnabled, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: LeaveSortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handleFromDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFromDate(e.target.value);
setCurrentPage(1);
};
const handleToDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setToDate(e.target.value);
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const handleWithdraw = (id: string) => {
const targetRecord = combinedRecords.find(r => r.id === id);
if (!targetRecord) return;
setLocalOverrides(prev => ({
...prev,
[id]: { ...targetRecord, status: 'Withdrawn' }
}));
toast.success("Leave application withdrawn successfully.");
setWithdrawRecord(null);
setViewRecord(null);
};
const getSortIcon = (key: LeaveSortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const getStatusClass = (status: string) => {
const normalizedStatus = status.toUpperCase();
if (normalizedStatus.includes('APPROVED')) return 'bg-present-100 text-present-700';
if (normalizedStatus.includes('PENDING')) return 'bg-late-100 text-late-700';
if (normalizedStatus.includes('REJECTED')) return 'bg-absent-100 text-absent-700';
if (normalizedStatus.includes('WITHDRAWN') || normalizedStatus.includes('CANCELLED')) return 'bg-app-muted text-text-secondary';
return 'bg-app-muted text-text-secondary';
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm mt-6 relative">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Leaves Applications</h2>
<div className="flex items-center space-x-2">
<div className="relative">
<Calendar size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="date" value={fromDate} onChange={handleFromDateChange} className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
</div>
<span className="text-text-muted text-sm">to</span>
<div className="relative">
<Calendar size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input type="date" value={toDate} onChange={handleToDateChange} className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card" />
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['leaveType', 'applicationDate', 'fromDate', 'toDate', 'leaveDays', 'status'] as LeaveSortKey[]).map((key) => (
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
<div className="flex items-center">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.leaveType}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{new Date(record.applicationDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{new Date(record.fromDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{new Date(record.toDate + 'T00:00:00').toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-primary">{record.leaveDays}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-2">
<button onClick={() => setViewRecord(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
{record.status.toUpperCase() === 'PENDING' && (
<button onClick={() => setWithdrawRecord(record)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Ban size={16} className="mr-1" /> Withdraw
</button>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-sm text-text-muted">No records found for the selected period.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={5}>5</option>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
{viewRecord && (
<ViewLeaveModal
record={viewRecord}
onClose={() => setViewRecord(null)}
onWithdraw={(rec) => {
setViewRecord(null);
setWithdrawRecord(rec);
}}
/>
)}
{withdrawRecord && (
<WithdrawLeaveModal
record={withdrawRecord}
onClose={() => setWithdrawRecord(null)}
onConfirm={handleWithdraw}
/>
)}
</div>
);
}

View File

@ -1,37 +0,0 @@
import type { LeaveSummary } from '../../../types/leave';
interface LeaveSummaryTableProps {
data: LeaveSummary[];
}
export default function LeaveSummaryTable({ data }: LeaveSummaryTableProps) {
return (
<div className="flex-1 bg-app-card p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold text-text-primary mb-6">Leave Summary</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Leave Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Credited</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Utilized</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Lapsed</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">Balance</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{data.map((leave, index) => (
<tr key={index} className="hover:bg-app-muted transition-colors">
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-text-primary">{leave.leaveType}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-text-secondary">{leave.credited}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-text-secondary">{leave.utilized}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-text-secondary">{leave.lapsed}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-bold text-primary">{leave.balance}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,71 +0,0 @@
import { useState } from 'react';
import { FileText } from 'lucide-react';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import { useLeaveBalance } from '../api/useLeaveData';
import BalanceFilters, { type BalanceFilterValues } from '../components/leave/balance/BalanceFilters';
import BalanceTable from '../components/leave/balance/BalanceTable';
import { useCompanies } from '../../masterData/api/useCompanyData';
export default function BalanceReport() {
const today = new Date();
const [filters, setFilters] = useState<BalanceFilterValues>({
companyId: '1',
month: today.getMonth() + 1,
year: today.getFullYear(),
});
const [isSearched, setIsSearched] = useState(false);
// Fetch companies for the dropdown
const { data: companiesData, isLoading: compLoading, isError: compError } = useCompanies();
const companies = companiesData?.filter(c => c.status === 'Active') || [];
// Fetch data from backend using month, year, and company_id.
// It only triggers when isSearched is true.
const { data: balanceData, isLoading: balanceLoading, isError: balanceError } = useLeaveBalance(
{ month: filters.month, year: filters.year, companyId: Number(filters.companyId) },
isSearched
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (!filters.companyId) {
alert("Please select a company");
return;
}
setIsSearched(true); // Triggers the fetch
};
if (compLoading || balanceLoading) return <Loader message="Loading Leave Balance data..." />;
if (compError || balanceError) return <ErrorState message="Failed to load Leave Balance data." />;
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<FileText className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Leave Balance Report</h2>
<p className="text-sm text-text-muted">Filter by company and month to view leave balances.</p>
</div>
</div>
<BalanceFilters
filters={filters}
companies={companies}
onFilterChange={setFilters}
onSearch={handleSearch}
/>
<BalanceTable
records={balanceData || []}
isSearched={isSearched}
month={filters.month}
year={filters.year}
/>
</div>
);
}

View File

@ -1,17 +0,0 @@
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import LeaveApplicationsTable from '../components/leave/table/LeaveApplicationsTable';
import { useLeaveApplications } from '../api/useLeaveData';
export default function LeaveApplications() {
const { data: records, isLoading, isError } = useLeaveApplications();
if (isLoading) return <Loader message="Loading leave applications..." />;
if (isError) return <ErrorState message="Failed to load leave applications." />;
return (
<div>
{records && <LeaveApplicationsTable records={records} />}
</div>
);
}

View File

@ -1,32 +0,0 @@
import { toast } from 'sonner';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import ApprovalTable from '../components/approval/ApprovalTable';
import { usePendingApprovals, useUpdateLeaveApproval } from '../api/useLeaveData';
export default function MyApprovals() {
// This hook automatically fetches the correct pending leaves
// based on the logged-in user's role via the Mock SSO headers.
const { data: approvals, isLoading, isError } = usePendingApprovals();
const { mutate } = useUpdateLeaveApproval();
const handleAction = (id: string, status: 'Approved' | 'Rejected') => {
mutate({ id, status }, {
onSuccess: () => {
toast.success(`Leave request ${status.toLowerCase()} successfully.`);
},
onError: () => {
toast.error("Failed to update leave status. Please try again.");
}
});
};
if (isLoading) return <Loader message="Loading pending approvals..." />;
if (isError) return <ErrorState message="Failed to load pending approvals." />;
return (
<div>
{approvals && <ApprovalTable records={approvals} onAction={handleAction} />}
</div>
);
}

View File

@ -1,25 +0,0 @@
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import LeaveSummaryTable from '../components/leave/table/LeaveSummaryTable';
import ApplyLeaveForm from '../components/leave/form/ApplyLeaveForm';
import LeaveHistoryTable from '../components/leave/table/LeaveHistoryTable';
import { useLeaveSummary, useLeaveHistory } from '../api/useLeaveData';
export default function MyLeave() {
// The hooks automatically use the Mock SSO headers injected by the Axios interceptor
const { data: summaryData, isLoading: summaryLoading, isError: summaryError } = useLeaveSummary();
const { data: historyData, isLoading: historyLoading, isError: historyError } = useLeaveHistory();
if (summaryLoading || historyLoading) return <Loader message="Loading leave data..." />;
if (summaryError || historyError) return <ErrorState message="Failed to load leave data." />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row gap-6">
{summaryData && <LeaveSummaryTable data={summaryData} />}
{summaryData && <ApplyLeaveForm summaryData={summaryData} />}
</div>
{historyData && <LeaveHistoryTable records={historyData} />}
</div>
);
}

View File

@ -1,14 +1,14 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import MyApprovals from '../pages/MyApprovals';
const ManagerApproval = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Pending Approvals</h2><p className="mt-2 text-text-muted">Approve or reject team leave and regularization requests.</p></div>;
export default function ApprovalRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
// Employees do not have approval access, so we block them at the router level.
if (role === 'manager' || role === 'hrmanager' || role === 'director') {
return <MyApprovals />;
if (role === 'manager') {
return <ManagerApproval />;
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: Managers, HR Managers, and Directors only.</div>;
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: Managers only.</div>;
}

View File

@ -1,20 +1,18 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import MyLeave from '../pages/MyLeave';
const EmployeeLeave = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">My Leaves</h2><p className="mt-2 text-text-muted">Apply for leave and view your leave history.</p></div>;
const ManagerLeave = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Team Leave Calendar</h2><p className="mt-2 text-text-muted">See who is on leave in your team.</p></div>;
const AdminLeave = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">All Leave Requests</h2><p className="mt-2 text-text-muted">Manage leave types and balances for branches.</p></div>;
export default function LeaveRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
// The Leave page layout is identical for all roles.
// The backend LMS service automatically handles role-based logic (like approval chains)
// based on the X-Mock-User-Role header sent from Redux.
switch (role) {
case 'employee':
case 'manager':
case 'hrmanager':
case 'director':
return <MyLeave />;
default:
return <div>Unauthorized</div>;
case 'employee': return <EmployeeLeave />;
case 'manager': return <ManagerLeave />;
case 'admin': return <ManagerLeave />;
case 'superadmin': return <AdminLeave />;
default: return <div>Unauthorized</div>;
}
}

View File

@ -1,13 +1,14 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import LeaveApplications from '../pages/LeaveApplications';
import BalanceReport from '../pages/BalanceReport';
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
const AdminLeaveSummary = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Leave Summary Report</h2><p className="mt-2 text-text-muted">Analyze leave trends across the organization.</p></div>;
export default function LeaveSummaryRouter() {
return (
<Routes>
<Route path="/" element={<Navigate to="/leave-summary/applications" replace />} />
<Route path="/applications" element={<LeaveApplications />} />
<Route path="/balance" element={<BalanceReport />} />
</Routes>
);
const role = useSelector((state: RootState) => state.role.currentRole);
if (role === 'admin' || role === 'superadmin') {
return <AdminLeaveSummary />;
}
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: Admins only.</div>;
}

View File

@ -1,77 +0,0 @@
export interface LeaveSummary {
leaveTypeId: number;
leaveType: string;
credited: number;
utilized: number;
lapsed: number;
balance: number;
}
export type DayMode = 'Full Day' | 'First Half' | 'Second Half';
export interface LeaveRequestPayload {
leaveTypeId: number;
leaveType: string;
fromDate: string;
fromDayMode: DayMode;
toDate: string;
toDayMode: DayMode;
leaveDays: number;
resumptionDate: string;
reason: string;
}
export type LeaveStatus = 'Approved' | 'Pending' | 'Withdrawn';
export interface LeaveHistoryRecord {
id: string;
leaveType: string;
applicationDate: string;
fromDate: string;
toDate: string;
leaveDays: number;
status: LeaveStatus;
reason: string;
}
export type LeaveSortKey = keyof LeaveHistoryRecord;
export type ApprovalStatus = 'Pending' | 'Approved' | 'Rejected';
export interface ManagerLeaveApproval {
id: string;
employeeId: string;
employeeName: string;
leaveType: string;
applicationDate: string;
fromDate: string;
toDate: string;
leaveDays: number;
status: ApprovalStatus;
reason: string;
}
export interface LeaveApplicationSummary {
id: string;
employeeId: string;
employeeName: string;
jobRole: string;
department: string;
managerName: string;
leaveType: string;
fromDate: string;
toDate: string;
leaveDays: number;
status: string;
reason: string;
}
export interface LeaveBalanceRecord {
employeeId: string;
employeeName: string;
casualOpening: number;
casualClosing: number;
optionalOpening: number;
optionalClosing: number;
}

View File

@ -1,80 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import { toast } from 'sonner';
import type { Branch } from '../types/masterData';
export const useBranches = () => {
return useQuery<Branch[]>({
queryKey: ['branches'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/branches');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((b: any) => ({
id: b.branchId,
code: b.branchCode || b.code,
name: b.branchName,
companyId: b.companyId || null,
companyCode: b.companyCode || '',
companyName: b.companyName || '',
status: b.isActive ? 'Active' : 'Disabled',
}));
},
});
};
export const useCreateBranch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; companyId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
branchName: data.name,
companyId: data.companyId,
isActive: data.status === 'Active',
};
const response = await apiClient.post('/api/ems/branches', payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['branches'] });
toast.success('Branch created successfully');
},
});
};
export const useUpdateBranch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, code, name, companyId, status }: { id: number; code: string; name: string; companyId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
branchName: name,
companyId: companyId,
code: code,
isActive: status === 'Active',
};
const response = await apiClient.put(`/api/ems/branches/${id}`, payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['branches'] });
toast.success('Branch updated successfully');
},
});
};
export const useDeleteBranch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiClient.delete(`/api/ems/branches/${id}`);
if (response.data && !response.data.success) {
throw new Error(response.data.message || "Failed to delete branch.");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['branches'] });
toast.success('Branch deleted successfully');
},
});
};

View File

@ -1,82 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import { toast } from 'sonner';
import type { Company } from '../types/masterData';
export const useCompanies = () => {
return useQuery<Company[]>({
queryKey: ['companies'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/companies');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((c: any) => ({
id: c.companyId,
code: c.companyCode,
name: c.name,
status: c.isActive ? 'Active' : 'Disabled',
}));
},
});
};
export const useCreateCompany = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; status: 'Active' | 'Disabled' }) => {
const payload = {
name: data.name,
parentId: null,
isActive: data.status === 'Active',
};
const response = await apiClient.post('/api/ems/companies', payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
toast.success('Company created successfully');
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error?.response?.data?.message || 'Failed to create company');
}
});
};
export const useUpdateCompany = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, name, status }: { id: number; name: string; status: 'Active' | 'Disabled' }) => {
const payload = {
name,
parentId: null,
isActive: status === 'Active',
};
const response = await apiClient.put(`/api/ems/companies/${id}`, payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
toast.success('Company updated successfully');
},
});
};
export const useDeleteCompany = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiClient.delete(`/api/ems/companies/${id}`);
if (response.data && response.data.success === false) {
throw new Error(response.data.message || 'Failed to delete company.');
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
toast.success('Company deleted successfully');
},
});
};

View File

@ -1,86 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import { toast } from 'sonner';
import type { Department } from '../types/masterData';
export const useDepartments = () => {
return useQuery<Department[]>({
queryKey: ['departments'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/departments');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((d: any) => ({
id: d.departmentId,
code: d.departmentCode || '',
name: d.departmentName || d.name,
branchId: d.branchId || null,
branchName: d.branchName || '',
companyId: d.companyId || null,
companyName: d.companyName || '',
status: d.isActive ? 'Active' : 'Disabled',
}));
},
});
};
export const useCreateDepartment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; companyId: number; branchId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
name: data.name,
companyId: data.companyId,
branchId: data.branchId,
parentId: null,
managerId: null,
isActive: data.status === 'Active',
};
const response = await apiClient.post('/api/ems/departments', payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] });
toast.success('Department created successfully');
},
});
};
export const useUpdateDepartment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, name, companyId, branchId, status }: { id: number; name: string; companyId: number; branchId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
name,
companyId,
branchId,
parentId: null,
managerId: null,
isActive: status === 'Active',
};
const response = await apiClient.put(`/api/ems/departments/${id}`, payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] });
toast.success('Department updated successfully');
},
});
};
export const useDeleteDepartment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiClient.delete(`/api/ems/departments/${id}`);
if (response.data && !response.data.success) {
throw new Error(response.data.message || "Failed to delete department.");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] });
toast.success('Department deleted successfully');
},
});
};

View File

@ -1,164 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import { toast } from 'sonner';
import type { Employee, EmployeeDetail, Manager } from '../types/masterData';
export const useEmployees = () => {
return useQuery<Employee[]>({
queryKey: ['employees'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/employees');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((e: any) => ({
id: e.employeeId,
code: e.employeeCode,
firstName: e.firstName || "",
lastName: e.lastName || "",
email: e.workEmail || "",
jobId: e.jobId || null,
jobName: e.jobName || "",
deptId: e.departmentId || null,
deptName: e.departmentName || "",
branchId: e.branchId || null,
branchName: e.branchName || "",
companyId: e.companyId || null,
companyName: e.companyName || "",
status: e.isActive ? 'Active' : 'Disabled',
}));
},
});
};
export const useEmployeeById = (id: number | null) => {
return useQuery<EmployeeDetail>({
queryKey: ['employee', id],
queryFn: async () => {
const response = await apiClient.get(`/api/ems/employees/${id}`);
return response.data.data as EmployeeDetail;
},
enabled: !!id,
});
};
export const useManagers = (deptId: number | null) => {
return useQuery<Manager[]>({
queryKey: ['managers', deptId],
queryFn: async () => {
if (!deptId) return [];
const response = await apiClient.get(`/api/ems/managers?department_id=${deptId}`);
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((m: any) => ({
id: m.employeeId,
code: m.employeeCode,
firstName: m.firstName,
lastName: m.lastName,
}));
},
enabled: !!deptId,
});
};
export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: async (data: any) => {
const payload = {
firstName: data.firstName,
lastName: data.lastName,
dob: data.dob,
gender: data.gender,
personalEmail: data.personalEmail,
personalPhone: data.personalPhone,
address: data.address,
companyId: data.companyId,
branchId: data.branchId,
departmentId: data.deptId,
jobId: data.jobId,
workEmail: data.workEmail,
dateJoining: data.dateJoining,
probationDays: data.probationDays || 90,
salaryStructureId: data.salaryStructureId || 1,
reportingToId: data.managerId || null,
isActive: data.status === 'Active'
};
try {
const response = await apiClient.post('/api/ems/employees', payload);
return response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
const backendError = error?.response?.data?.message || error?.response?.data?.error || "Unknown backend error";
throw new Error(backendError, { cause: error });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employees'] });
toast.success('Employee created successfully');
},
});
};
export const useUpdateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: async (data: any) => {
// status is handled by PATCH endpoint
const payload = {
firstName: data.firstName,
lastName: data.lastName,
dob: data.dob,
gender: data.gender,
personalEmail: data.personalEmail,
personalPhone: data.personalPhone,
address: data.address,
companyId: data.companyId,
branchId: data.branchId,
departmentId: data.deptId,
jobId: data.jobId,
workEmail: data.workEmail,
dateJoining: data.dateJoining,
reportingToId: data.managerId || null,
};
try {
const response = await apiClient.put(`/api/ems/employees/${data.id}`, payload);
return response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
const backendError = error?.response?.data?.message || error?.response?.data?.error || "Unknown backend error";
throw new Error(backendError, { cause: error });
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['employees'], refetchType: 'active' });
if (variables.id) {
queryClient.invalidateQueries({ queryKey: ['employee', variables.id] });
}
},
});
};
export const useUpdateEmployeeStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
try {
const response = await apiClient.patch(`/api/ems/employees/${id}/status`, { isActive });
return response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
const backendError = error?.response?.data?.message || error?.response?.data?.error || "Unknown backend error";
throw new Error(backendError, { cause: error });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employees'] });
},
});
};

View File

@ -1,10 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetchMockData } from '../../../api/client';
import type { HRManagerEmployee } from '../types/masterData';
export const useHRManagerEmployees = () => {
return useQuery<HRManagerEmployee[]>({
queryKey: ['HRManagerEmployees'],
queryFn: () => fetchMockData<HRManagerEmployee[]>('master-data/hrmanagers.json'),
});
};

View File

@ -1,85 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import { toast } from 'sonner';
import type { JobRole } from '../types/masterData';
export const useJobRoles = () => {
return useQuery<JobRole[]>({
queryKey: ['jobRoles'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/jobs');
const rawData = response.data.data || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rawData.map((d: any) => ({
id: d.jobId,
code: d.jobCode || '',
name: d.jobName || '',
description: d.description || '',
deptId: d.departmentId || null,
deptName: d.departmentName || '',
branchId: d.branchId || null,
branchName: d.branchName || '',
companyId: d.companyId || null,
companyName: d.companyName || '',
status: d.isActive ? 'Active' : 'Disabled',
}));
},
});
};
export const useCreateJobRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description: string; deptId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
departmentId: data.deptId,
title: data.name,
description: data.description,
isActive: data.status === 'Active',
};
const response = await apiClient.post('/api/ems/jobs', payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobRoles'] });
toast.success('Job Role created successfully');
},
});
};
export const useUpdateJobRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, name, description, deptId, status }: { id: number; name: string; description: string; deptId: number; status: 'Active' | 'Disabled' }) => {
const payload = {
departmentId: deptId,
title: name,
description: description,
isActive: status === 'Active',
};
const response = await apiClient.put(`/api/ems/jobs/${id}`, payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobRoles'] });
toast.success('Job Role updated successfully');
},
});
};
export const useDeleteJobRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiClient.delete(`/api/ems/jobs/${id}`);
if (response.data && !response.data.success) {
throw new Error(response.data.message || "Failed to delete job role.");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobRoles'] });
toast.success('Job Role deleted successfully');
},
});
};

View File

@ -1,21 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../../api/client';
import type { MasterDataSummary } from '../types/masterData';
export const useMasterDataSummary = () => {
return useQuery<MasterDataSummary>({
queryKey: ['masterDataSummary'],
queryFn: async () => {
const response = await apiClient.get('/api/ems/dashboard/metrics');
const d = response.data.data;
return {
totalCompanies: d.total_companies ?? d.totalCompanies ?? 0,
totalBranches: d.total_branches ?? d.totalBranches ?? 0,
totalDepartments: d.total_departments ?? d.totalDepartments ?? 0,
totalJobRoles: d.total_jobs ?? d.totalJobs ?? 0,
totalEmployees: d.total_employees ?? d.totalEmployees ?? 0,
};
},
});
};

View File

@ -1,10 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetchMockData } from '../../../api/client';
import type { CompanyPolicy } from '../types/masterData';
export const usePolicies = () => {
return useQuery<CompanyPolicy[]>({
queryKey: ['policies'],
queryFn: () => fetchMockData<CompanyPolicy[]>('policies.json'),
});
};

View File

@ -1,111 +0,0 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { Branch, Company } from '../../types/masterData';
interface BranchFormModalProps {
branch: Branch | null;
companies: Company[];
onClose: () => void;
onSave: (name: string, companyId: number, status: 'Active' | 'Disabled') => void;
}
export default function BranchFormModal({ branch, companies, onClose, onSave }: BranchFormModalProps) {
const [name, setName] = useState('');
const [companyId, setCompanyId] = useState<string>('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
useEffect(() => {
if (branch) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(branch.name);
setStatus(branch.status);
// If backend GET provided companyId, use it. Otherwise, find it by companyCode.
if (branch.companyId) {
setCompanyId(String(branch.companyId));
} else {
const matchedCompany = companies.find(c => c.code === branch.companyCode);
setCompanyId(matchedCompany ? String(matchedCompany.id) : (companies.length > 0 ? String(companies[0].id) : ''));
}
} else {
setName('');
setCompanyId(companies.length > 0 ? String(companies[0].id) : '');
setStatus('Active');
}
}, [branch, companies]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!companyId) {
alert("Please select a company");
return;
}
onSave(name, Number(companyId), status);
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card";
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">
{branch ? 'Edit Branch' : 'Add New Branch'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Branch Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className={inputClass}
placeholder="Enter branch name..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Company</label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
required
className={inputClass}
>
{companies.length === 0 ? (
<option value="">No companies available</option>
) : (
companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')}
className={inputClass}
>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button type="submit" disabled={companies.length === 0} className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
{branch ? 'Update' : 'Add'} Branch
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,180 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pencil, Trash2 } from 'lucide-react';
import type { Branch } from '../../types/masterData';
interface BranchTableProps {
records: Branch[];
onEdit: (branch: Branch) => void;
onDelete: (branch: Branch) => void;
}
type SortKey = 'code' | 'name' | 'companyName' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function BranchTable({ records, onEdit, onDelete }: BranchTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'name',
direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Branches List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search code, name, company..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['code', 'name', 'companyName', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key === 'code' ? 'Branch Code' : key === 'companyName' ? 'Company' : key.charAt(0).toUpperCase() + key.slice(1)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-3">
<button onClick={() => onEdit(record)} className="text-primary hover:text-primary-hover flex items-center">
<Pencil size={16} className="mr-1" /> Edit
</button>
<button onClick={() => onDelete(record)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Trash2 size={16} className="mr-1" /> Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-sm text-text-muted">No branches found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,78 +0,0 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { Company } from '../../types/masterData';
interface CompanyFormModalProps {
company: Company | null;
onClose: () => void;
onSave: (name: string, status: 'Active' | 'Disabled') => void;
}
export default function CompanyFormModal({ company, onClose, onSave }: CompanyFormModalProps) {
const [name, setName] = useState('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
useEffect(() => {
if (company) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(company.name);
setStatus(company.status);
} else {
setName('');
setStatus('Active');
}
}, [company]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(name, status);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">
{company ? 'Edit Company' : 'Add New Company'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Company Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
placeholder="Enter company name..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium">
{company ? 'Update' : 'Add'} Company
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,178 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pencil, Trash2 } from 'lucide-react';
import type { Company } from '../../types/masterData';
interface CompanyTableProps {
records: Company[];
onEdit: (company: Company) => void;
onDelete: (company: Company) => void;
}
type SortKey = 'code' | 'name' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function CompanyTable({ records, onEdit, onDelete }: CompanyTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'name',
direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Companies List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search code, name, status..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['code', 'name', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key === 'code' ? 'Company Code' : key.charAt(0).toUpperCase() + key.slice(1)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-3">
<button onClick={() => onEdit(record)} className="text-primary hover:text-primary-hover flex items-center">
<Pencil size={16} className="mr-1" /> Edit
</button>
<button onClick={() => onDelete(record)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Trash2 size={16} className="mr-1" /> Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-sm text-text-muted">No companies found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,155 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import type { Department, Company, Branch } from '../../types/masterData';
interface DepartmentFormModalProps {
department: Department | null;
companies: Company[];
branches: Branch[];
onClose: () => void;
onSave: (data: { name: string; companyId: number; branchId: number; status: 'Active' | 'Disabled' }) => void;
}
export default function DepartmentFormModal({ department, companies, branches, onClose, onSave }: DepartmentFormModalProps) {
const [name, setName] = useState('');
const [companyId, setCompanyId] = useState<string>('');
const [branchId, setBranchId] = useState<string>('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
useEffect(() => {
if (department) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(department.name);
setStatus(department.status);
if (department.companyId) {
setCompanyId(String(department.companyId));
} else {
const matchedCompany = companies.find(c => c.name === department.companyName);
setCompanyId(matchedCompany ? String(matchedCompany.id) : (companies.length > 0 ? String(companies[0].id) : ''));
}
if (department.branchId) {
setBranchId(String(department.branchId));
} else {
const matchedBranch = branches.find(b => b.name === department.branchName);
setBranchId(matchedBranch ? String(matchedBranch.id) : '');
}
} else {
setName('');
setCompanyId(companies.length > 0 ? String(companies[0].id) : '');
setBranchId('');
setStatus('Active');
}
}, [department, companies, branches]);
// Filter branches by matching the Company Name
const filteredBranches = useMemo(() => {
const selectedCompany = companies.find(c => String(c.id) === companyId);
if (!selectedCompany) return [];
return branches.filter(b => b.companyName === selectedCompany.name);
}, [branches, companyId, companies]);
const handleCompanyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCompanyId(e.target.value);
setBranchId(''); // Reset branch when company changes
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!companyId || !branchId) {
alert("Please select a company and branch");
return;
}
onSave({
name,
companyId: Number(companyId),
branchId: Number(branchId),
status,
});
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card";
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">
{department ? 'Edit Department' : 'Add New Department'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Department Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className={inputClass}
placeholder="Enter department name..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Company</label>
<select
value={companyId}
onChange={handleCompanyChange}
required
className={inputClass}
>
{companies.length === 0 ? (
<option value="">No companies available</option>
) : (
companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Branch</label>
<select
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
required
disabled={!companyId || filteredBranches.length === 0}
className={`${inputClass} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<option value="">Select Branch</option>
{filteredBranches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
{companyId && filteredBranches.length === 0 && (
<p className="text-xs text-absent-500 mt-1">No branches found for this company. Please add a branch first.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')}
className={inputClass}
>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button type="submit" disabled={!branchId} className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
{department ? 'Update' : 'Add'} Department
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,183 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pencil, Trash2 } from 'lucide-react';
import type { Department } from '../../types/masterData';
interface DepartmentTableProps {
records: Department[];
onEdit: (department: Department) => void;
onDelete: (department: Department) => void;
}
type SortKey = 'code' | 'name' | 'branchName' | 'companyName' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function DepartmentTable({ records, onEdit, onDelete }: DepartmentTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'name',
direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.branchName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Departments List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search code, name, branch..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['code', 'name', 'branchName', 'companyName', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key === 'code' ? 'Dept Code' : key === 'name' ? 'Department' : key === 'branchName' ? 'Branch' : key === 'companyName' ? 'Company' : key.charAt(0).toUpperCase() + key.slice(1)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.branchName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-3">
<button onClick={() => onEdit(record)} className="text-primary hover:text-primary-hover flex items-center">
<Pencil size={16} className="mr-1" /> Edit
</button>
<button onClick={() => onDelete(record)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Trash2 size={16} className="mr-1" /> Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-text-muted">No departments found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,363 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import type { Employee, Company, Branch, Department, JobRole } from '../../types/masterData';
import { useEmployeeById, useManagers } from '../../api/useEmployeeData';
interface EmployeeFormModalProps {
employee: Employee | null;
companies: Company[];
branches: Branch[];
departments: Department[];
jobRoles: JobRole[];
onClose: () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave: (data: any) => void;
}
const parseDate = (dateStr: string | undefined) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function EmployeeFormModal({ employee, companies, branches, departments, jobRoles, onClose, onSave }: EmployeeFormModalProps) {
const { data: empDetail } = useEmployeeById(employee ? employee.id : null);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [dob, setDob] = useState('');
const [gender, setGender] = useState('Male');
const [personalEmail, setPersonalEmail] = useState('');
const [workEmail, setWorkEmail] = useState('');
const [personalPhone, setPersonalPhone] = useState('');
const [dateJoining, setDateJoining] = useState('');
const [addrDoor, setAddrDoor] = useState('');
const [addrLandmark, setAddrLandmark] = useState('');
const [addrLine, setAddrLine] = useState('');
const [addrPincode, setAddrPincode] = useState('');
const [addrDistrict, setAddrDistrict] = useState('');
const [addrState, setAddrState] = useState('');
const [companyId, setCompanyId] = useState<string>('');
const [branchId, setBranchId] = useState<string>('');
const [deptId, setDeptId] = useState<string>('');
const [jobId, setJobId] = useState<string>('');
const [managerId, setManagerId] = useState<string>('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
const [loadedEmpId, setLoadedEmpId] = useState<number | null>(null);
// Fetch managers based on selected department
const { data: managers } = useManagers(deptId ? Number(deptId) : null);
useEffect(() => {
if (employee && empDetail && employee.id !== loadedEmpId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadedEmpId(employee.id);
setFirstName(empDetail.firstName || '');
setLastName(empDetail.lastName || '');
setDob(parseDate(empDetail.dob));
setGender(empDetail.gender || 'Male');
setPersonalEmail(empDetail.personalEmail || '');
setPersonalPhone(empDetail.personalPhone || '');
setWorkEmail(empDetail.workEmail || '');
setDateJoining(parseDate(empDetail.dateJoining));
setStatus(empDetail.isActive ? 'Active' : 'Disabled');
const address = empDetail.addresses && empDetail.addresses.length > 0 ? empDetail.addresses[0] : {};
setAddrDoor(address.doorNumber || '');
setAddrLandmark(address.landmark || '');
setAddrLine(address.line || '');
setAddrPincode(address.pincode || '');
setAddrDistrict(address.district || '');
setAddrState(address.state || '');
setCompanyId(String(empDetail.companyId || ''));
setBranchId(String(empDetail.branchId || ''));
setDeptId(String(empDetail.departmentId || ''));
setJobId(String(empDetail.jobId || ''));
}
else if (!employee && loadedEmpId !== null) {
setLoadedEmpId(null);
setFirstName('');
setLastName('');
setDob('');
setGender('Male');
setPersonalEmail('');
setWorkEmail('');
setPersonalPhone('');
setDateJoining('');
setAddrDoor('');
setAddrLandmark('');
setAddrLine('');
setAddrPincode('');
setAddrDistrict('');
setAddrState('');
setCompanyId(companies.length > 0 ? String(companies[0].id) : '');
setBranchId('');
setDeptId('');
setJobId('');
setManagerId('');
setStatus('Active');
}
}, [employee, empDetail, loadedEmpId, companies]);
// Effect to set manager ID when managers list loads
useEffect(() => {
if (empDetail && managers && managers.length > 0 && employee && loadedEmpId === employee.id) {
const matchedManager = managers.find(m => m.code === empDetail.managerEmployeeCode);
if (matchedManager) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setManagerId(String(matchedManager.id));
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setManagerId('');
}
}
}, [empDetail, managers, employee, loadedEmpId]);
// --- CASCADING DROPDOWN LOGIC (FILTERING BY NAME TO MATCH BACKEND RESPONSES) ---
const filteredBranches = useMemo(() => {
if (!companyId) return [];
const selectedCompany = companies.find(c => String(c.id) === companyId);
if (!selectedCompany) return [];
// Filter by companyName OR companyId
return branches.filter(b => b.companyName === selectedCompany.name || String(b.companyId) === companyId);
}, [branches, companyId, companies]);
const filteredDepartments = useMemo(() => {
if (!branchId) return [];
const selectedBranch = branches.find(b => String(b.id) === branchId);
if (!selectedBranch) return [];
return departments.filter(d => d.branchName === selectedBranch.name || String(d.branchId) === branchId);
}, [departments, branchId, branches]);
const filteredJobRoles = useMemo(() => {
if (!deptId) return [];
const selectedDept = departments.find(d => String(d.id) === deptId);
if (!selectedDept) return [];
return jobRoles.filter(j => j.deptName === selectedDept.name || String(j.deptId) === deptId);
}, [jobRoles, deptId, departments]);
const handleCompanyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCompanyId(e.target.value);
setBranchId('');
setDeptId('');
setJobId('');
setManagerId('');
};
const handleBranchChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setBranchId(e.target.value);
setDeptId('');
setJobId('');
setManagerId('');
};
const handleDeptChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setDeptId(e.target.value);
setJobId('');
setManagerId('');
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!companyId || !branchId || !deptId || !jobId) {
alert("Please select Company, Branch, Department, and Job Role");
return;
}
onSave({
firstName,
lastName,
dob: dob || '1990-01-01',
gender,
personalEmail,
workEmail,
personalPhone,
dateJoining: dateJoining || new Date().toISOString().split('T')[0],
address: {
type: "CURRENT",
doorNumber: addrDoor || "N/A",
landmark: addrLandmark || "N/A",
line: addrLine || "N/A",
pincode: addrPincode || "560001",
district: addrDistrict || "Bengaluru",
state: addrState || "Karnataka"
},
companyId: Number(companyId),
branchId: Number(branchId),
deptId: Number(deptId),
jobId: Number(jobId),
managerId: managerId ? Number(managerId) : null,
status,
});
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card";
const labelClass = "block text-sm font-medium text-text-secondary mb-1";
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-lg p-6 relative max-h-[90vh] overflow-y-auto">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">
{employee ? 'Edit Employee' : 'Add New Employee'}
</h3>
{employee && !empDetail ? (
<div className="py-10 text-center text-text-muted">Loading employee data...</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>First Name</label>
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} required className={inputClass} />
</div>
<div>
<label className={labelClass}>Last Name</label>
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} required className={inputClass} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Date of Birth</label>
<input type="date" value={dob} onChange={(e) => setDob(e.target.value)} required className={inputClass} />
</div>
<div>
<label className={labelClass}>Gender</label>
<select value={gender} onChange={(e) => setGender(e.target.value)} className={inputClass}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Personal Email</label>
<input type="email" value={personalEmail} onChange={(e) => setPersonalEmail(e.target.value)} required className={inputClass} />
</div>
<div>
<label className={labelClass}>Work Email</label>
<input type="email" value={workEmail} onChange={(e) => setWorkEmail(e.target.value)} required className={inputClass} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Phone</label>
<input type="tel" value={personalPhone} onChange={(e) => setPersonalPhone(e.target.value)} required className={inputClass} />
</div>
<div>
<label className={labelClass}>Date Joining</label>
<input type="date" value={dateJoining} onChange={(e) => setDateJoining(e.target.value)} required className={inputClass} />
</div>
</div>
<div className="border-t border-app-border pt-4 mt-4">
<h4 className="text-sm font-semibold text-text-primary mb-2">Address</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Door Number</label>
<input type="text" value={addrDoor} onChange={(e) => setAddrDoor(e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Landmark</label>
<input type="text" value={addrLandmark} onChange={(e) => setAddrLandmark(e.target.value)} className={inputClass} />
</div>
</div>
<div className="mt-2">
<label className={labelClass}>Address Line</label>
<input type="text" value={addrLine} onChange={(e) => setAddrLine(e.target.value)} className={inputClass} />
</div>
<div className="grid grid-cols-3 gap-4 mt-2">
<div>
<label className={labelClass}>Pincode</label>
<input type="text" value={addrPincode} onChange={(e) => setAddrPincode(e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>District</label>
<input type="text" value={addrDistrict} onChange={(e) => setAddrDistrict(e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>State</label>
<input type="text" value={addrState} onChange={(e) => setAddrState(e.target.value)} className={inputClass} />
</div>
</div>
</div>
<div className="border-t border-app-border pt-4 mt-4">
<h4 className="text-sm font-semibold text-text-primary mb-2">Organization</h4>
<div>
<label className={labelClass}>Company</label>
<select value={companyId} onChange={handleCompanyChange} required className={inputClass}>
<option value="">Select Company</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="mt-2">
<label className={labelClass}>Branch</label>
<select value={branchId} onChange={handleBranchChange} required disabled={!companyId || filteredBranches.length === 0} className={`${inputClass} disabled:opacity-50 disabled:cursor-not-allowed`}>
<option value="">Select Branch</option>
{filteredBranches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div className="mt-2">
<label className={labelClass}>Department</label>
<select value={deptId} onChange={handleDeptChange} required disabled={!branchId || filteredDepartments.length === 0} className={`${inputClass} disabled:opacity-50 disabled:cursor-not-allowed`}>
<option value="">Select Department</option>
{filteredDepartments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div className="mt-2">
<label className={labelClass}>Job Role</label>
<select value={jobId} onChange={(e) => setJobId(e.target.value)} required disabled={!deptId || filteredJobRoles.length === 0} className={`${inputClass} disabled:opacity-50 disabled:cursor-not-allowed`}>
<option value="">Select Job Role</option>
{filteredJobRoles.map(j => <option key={j.id} value={j.id}>{j.name}</option>)}
</select>
</div>
<div className="mt-2">
<label className={labelClass}>Manager</label>
<select value={managerId} onChange={(e) => setManagerId(e.target.value)} disabled={!deptId || !managers || managers.length === 0} className={`${inputClass} disabled:opacity-50 disabled:cursor-not-allowed`}>
<option value="">Select Manager</option>
{managers && managers.map(m => <option key={m.id} value={m.id}>{m.firstName} {m.lastName} ({m.code})</option>)}
</select>
</div>
<div className="mt-2">
<label className={labelClass}>Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')} className={inputClass}>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">Cancel</button>
<button type="submit" disabled={!jobId} className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
{employee ? 'Update' : 'Add'} Employee
</button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -1,193 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye } from 'lucide-react';
import type { Employee } from '../../types/masterData';
interface EmployeeTableProps {
records: Employee[];
onView: (employee: Employee) => void;
}
type SortKey = 'code' | 'firstName' | 'lastName' | 'email' | 'jobName' | 'deptName' | 'branchName' | 'companyName' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function EmployeeTable({ records, onView }: EmployeeTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'firstName', direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.firstName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.jobName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.deptName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.branchName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const formatHeader = (key: string) => {
if (key === 'code') return 'Emp Code';
if (key === 'firstName') return 'First Name';
if (key === 'lastName') return 'Last Name';
if (key === 'email') return 'Work Email';
if (key === 'jobName') return 'Job Role';
if (key === 'deptName') return 'Department';
if (key === 'branchName') return 'Branch';
if (key === 'companyName') return 'Company';
return key.charAt(0).toUpperCase() + key.slice(1);
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Employees List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search code, name, role..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['code', 'firstName', 'lastName', 'email', 'jobName', 'deptName', 'branchName', 'companyName', 'status'] as SortKey[]).map((key) => (
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
<div className="flex items-center">
{formatHeader(key)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.firstName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.lastName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.email}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.jobName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.deptName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.branchName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button onClick={() => onView(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="px-6 py-8 text-center text-sm text-text-muted">No employees found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,147 +0,0 @@
import { X, Pencil } from 'lucide-react';
import type { Employee } from '../../types/masterData';
import { useEmployeeById } from '../../api/useEmployeeData';
interface EmployeeViewModalProps {
employee: Employee;
onClose: () => void;
onEdit: (employee: Employee) => void;
}
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function EmployeeViewModal({ employee, onClose, onEdit }: EmployeeViewModalProps) {
const { data: empDetail, isLoading } = useEmployeeById(employee.id);
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-lg p-6 relative max-h-[90vh] overflow-y-auto">
<div className="absolute top-4 right-4 flex items-center space-x-2">
<button onClick={() => onEdit(employee)} className="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors" title="Edit">
<Pencil size={20} />
</button>
<button onClick={onClose} className="p-2 text-text-muted hover:text-text-primary" title="Close">
<X size={24} />
</button>
</div>
<h3 className="text-xl font-bold text-text-primary mb-6">Employee Details</h3>
{isLoading || !empDetail ? (
<div className="py-10 text-center text-text-muted">Loading employee data...</div>
) : (
<div className="space-y-6">
{/* Personal Details */}
<div>
<h4 className="text-sm font-semibold text-text-primary border-b border-app-border pb-2 mb-3">Personal Details</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs text-text-muted block">Employee Code</span>
<span className="text-sm font-medium text-text-primary">{empDetail.employeeCode}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(employee.status)}`}>{employee.status}</span>
</div>
<div>
<span className="text-xs text-text-muted block">First Name</span>
<span className="text-sm font-medium text-text-primary">{empDetail.firstName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Last Name</span>
<span className="text-sm font-medium text-text-primary">{empDetail.lastName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Gender</span>
<span className="text-sm font-medium text-text-primary">{empDetail.gender}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Date of Birth</span>
<span className="text-sm font-medium text-text-primary">{formatDate(empDetail.dob)}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Personal Email</span>
<span className="text-sm font-medium text-text-primary break-all">{empDetail.personalEmail}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Phone</span>
<span className="text-sm font-medium text-text-primary">{empDetail.personalPhone}</span>
</div>
</div>
</div>
{/* Address */}
<div>
<h4 className="text-sm font-semibold text-text-primary border-b border-app-border pb-2 mb-3">Address</h4>
{empDetail.addresses && empDetail.addresses.length > 0 ? (
<div className="text-sm text-text-secondary space-y-1">
<p>{empDetail.addresses[0].doorNumber}, {empDetail.addresses[0].line}</p>
<p>{empDetail.addresses[0].landmark}</p>
<p>{empDetail.addresses[0].district}, {empDetail.addresses[0].state} - {empDetail.addresses[0].pincode}</p>
</div>
) : (
<p className="text-sm text-text-muted">No address available</p>
)}
</div>
{/* Work Details */}
<div>
<h4 className="text-sm font-semibold text-text-primary border-b border-app-border pb-2 mb-3">Work Details</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs text-text-muted block">Work Email</span>
<span className="text-sm font-medium text-text-primary break-all">{empDetail.workEmail || '-'}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Date Joining</span>
<span className="text-sm font-medium text-text-primary">{formatDate(empDetail.dateJoining)}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Company</span>
<span className="text-sm font-medium text-text-primary">{empDetail.companyName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Branch</span>
<span className="text-sm font-medium text-text-primary">{empDetail.branchName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Department</span>
<span className="text-sm font-medium text-text-primary">{empDetail.department}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Job Role</span>
<span className="text-sm font-medium text-text-primary">{empDetail.jobName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Manager Name</span>
<span className="text-sm font-medium text-text-primary">{empDetail.managerFirstName} {empDetail.managerLastName}</span>
</div>
<div>
<span className="text-xs text-text-muted block">Manager Code</span>
<span className="text-sm font-medium text-text-primary">{empDetail.managerEmployeeCode || '-'}</span>
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end mt-6">
<button onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Close
</button>
</div>
</div>
</div>
);
}

View File

@ -1,216 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { X, Pencil } from 'lucide-react';
import type { JobRole, Company, Branch, Department } from '../../types/masterData';
interface JobRoleFormModalProps {
mode: 'view' | 'edit' | 'add';
jobRole: JobRole | null;
companies: Company[];
branches: Branch[];
departments: Department[];
onClose: () => void;
onSave: (data: { name: string; description: string; companyId: number; branchId: number; deptId: number; status: 'Active' | 'Disabled' }) => void;
onEdit: () => void;
}
export default function JobRoleFormModal({ mode, jobRole, companies, branches, departments, onClose, onSave, onEdit }: JobRoleFormModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [companyId, setCompanyId] = useState<string>('');
const [branchId, setBranchId] = useState<string>('');
const [deptId, setDeptId] = useState<string>('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
const isViewMode = mode === 'view';
useEffect(() => {
if (jobRole) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(jobRole.name);
setDescription(jobRole.description || '');
setStatus(jobRole.status);
if (jobRole.companyId) {
setCompanyId(String(jobRole.companyId));
} else {
const matchedCompany = companies.find(c => c.name === jobRole.companyName);
setCompanyId(matchedCompany ? String(matchedCompany.id) : (companies.length > 0 ? String(companies[0].id) : ''));
}
if (jobRole.branchId) {
setBranchId(String(jobRole.branchId));
} else {
const matchedBranch = branches.find(b => b.name === jobRole.branchName);
setBranchId(matchedBranch ? String(matchedBranch.id) : '');
}
if (jobRole.deptId) {
setDeptId(String(jobRole.deptId));
} else {
const matchedDept = departments.find(d => d.name === jobRole.deptName);
setDeptId(matchedDept ? String(matchedDept.id) : '');
}
} else {
setName('');
setDescription('');
setCompanyId(companies.length > 0 ? String(companies[0].id) : '');
setBranchId('');
setDeptId('');
setStatus('Active');
}
}, [jobRole, companies, branches, departments]);
const filteredBranches = useMemo(() => {
const selectedCompany = companies.find(c => String(c.id) === companyId);
if (!selectedCompany) return [];
return branches.filter(b => b.companyName === selectedCompany.name);
}, [branches, companyId, companies]);
const filteredDepartments = useMemo(() => {
const selectedBranch = branches.find(b => String(b.id) === branchId);
if (!selectedBranch) return [];
return departments.filter(d => d.branchName === selectedBranch.name);
}, [departments, branchId, branches]);
const handleCompanyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCompanyId(e.target.value);
setBranchId('');
setDeptId('');
};
const handleBranchChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setBranchId(e.target.value);
setDeptId('');
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!companyId || !branchId || !deptId) {
alert("Please select Company, Branch, and Department");
return;
}
onSave({
name,
description,
companyId: Number(companyId),
branchId: Number(branchId),
deptId: Number(deptId),
status,
});
};
const inputClass = "w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card disabled:opacity-70 disabled:cursor-not-allowed";
const labelClass = "block text-sm font-medium text-text-secondary mb-1";
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative max-h-[90vh] overflow-y-auto">
<div className="absolute top-4 right-4 flex items-center space-x-2">
{isViewMode && (
<button onClick={onEdit} className="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors" title="Edit">
<Pencil size={20} />
</button>
)}
<button onClick={onClose} className="p-2 text-text-muted hover:text-text-primary" title="Close">
<X size={24} />
</button>
</div>
<h3 className="text-xl font-bold text-text-primary mb-6">
{isViewMode ? 'View Job Role' : mode === 'edit' ? 'Edit Job Role' : 'Add New Job Role'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={labelClass}>Job Role Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isViewMode}
className={inputClass}
placeholder="Enter job role name..."
/>
</div>
<div>
<label className={labelClass}>Company</label>
<select
value={companyId}
onChange={handleCompanyChange}
required
disabled={isViewMode}
className={inputClass}
>
{companies.length === 0 ? <option value="">No companies available</option> : companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className={labelClass}>Branch</label>
<select
value={branchId}
onChange={handleBranchChange}
required
disabled={isViewMode || !companyId || filteredBranches.length === 0}
className={inputClass}
>
<option value="">Select Branch</option>
{filteredBranches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div>
<label className={labelClass}>Department</label>
<select
value={deptId}
onChange={(e) => setDeptId(e.target.value)}
required
disabled={isViewMode || !branchId || filteredDepartments.length === 0}
className={inputClass}
>
<option value="">Select Department</option>
{filteredDepartments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
disabled={isViewMode}
className={`${inputClass} resize-y`}
placeholder="Enter job description..."
/>
</div>
<div>
<label className={labelClass}>Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')}
disabled={isViewMode}
className={inputClass}
>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
{!isViewMode && (
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">Cancel</button>
<button type="submit" disabled={!deptId} className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
{mode === 'edit' ? 'Update' : 'Add'} Job Role
</button>
</div>
)}
</form>
</div>
</div>
);
}

View File

@ -1,188 +0,0 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye, Trash2 } from 'lucide-react';
import type { JobRole } from '../../types/masterData';
interface JobRoleTableProps {
records: JobRole[];
onView: (jobRole: JobRole) => void;
onDelete: (jobRole: JobRole) => void;
}
type SortKey = 'code' | 'name' | 'deptName' | 'branchName' | 'companyName' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function JobRoleTable({ records, onView, onDelete }: JobRoleTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'name', direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.deptName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.branchName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
const formatHeader = (key: string) => {
if (key === 'code') return 'Job Code';
if (key === 'name') return 'Job Role';
if (key === 'deptName') return 'Department';
if (key === 'branchName') return 'Branch';
if (key === 'companyName') return 'Company';
return key.charAt(0).toUpperCase() + key.slice(1);
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Job Roles List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search code, role, dept..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['code', 'name', 'deptName', 'branchName', 'companyName', 'status'] as SortKey[]).map((key) => (
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
<div className="flex items-center">
{formatHeader(key)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.deptName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.branchName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-3">
<button onClick={() => onView(record)} className="text-primary hover:text-primary-hover flex items-center">
<Eye size={16} className="mr-1" /> View
</button>
<button onClick={() => onDelete(record)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Trash2 size={16} className="mr-1" /> Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-sm text-text-muted">No job roles found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm-inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,141 +0,0 @@
import { useState } from 'react';
import { Network, Plus } from 'lucide-react';
import { toast } from 'sonner';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import ConfirmationModal from '../../../components/ui/ConfirmationModal';
import BranchTable from '../components/branch/BranchTable';
import BranchFormModal from '../components/branch/BranchFormModal';
import { useBranches, useCreateBranch, useUpdateBranch, useDeleteBranch } from '../api/useBranchData';
import { useCompanies } from '../api/useCompanyData';
import type { Branch } from '../types/masterData';
export default function ManageBranches() {
const { data: branches, isLoading: branchesLoading, isError: branchesError } = useBranches();
const { data: companies, isLoading: companiesLoading, isError: companiesError } = useCompanies();
const createMutation = useCreateBranch();
const updateMutation = useUpdateBranch();
const deleteMutation = useDeleteBranch();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBranch, setEditingBranch] = useState<Branch | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
// Filter to only show Active companies in the dropdown
const activeCompanies = companies?.filter(c => c.status === 'Active') || [];
const handleAddClick = () => {
setEditingBranch(null);
setIsModalOpen(true);
};
const handleEditClick = (branch: Branch) => {
setEditingBranch(branch);
setIsModalOpen(true);
};
const handleDeleteClick = (branch: Branch) => {
setDeleteTarget(branch);
};
const confirmDelete = () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success("Branch deleted successfully.");
setDeleteTarget(null);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error.message || "Failed to delete branch.");
setDeleteTarget(null);
},
});
}
};
const handleSave = (name: string, companyId: number, status: 'Active' | 'Disabled') => {
if (editingBranch) {
updateMutation.mutate(
{
id: editingBranch.id,
code: editingBranch.code,
name,
companyId,
status
},
{
onSuccess: () => {
toast.success("Branch updated successfully.");
setIsModalOpen(false);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error.message || "Failed to update branch.");
},
}
);
} else {
createMutation.mutate(
{ name, companyId, status },
{
onSuccess: () => {
toast.success("Branch added successfully.");
setIsModalOpen(false);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error.message || "Failed to add branch.");
},
}
);
}
};
if (branchesLoading || companiesLoading) return <Loader message="Loading branches data..." />;
if (branchesError || companiesError) return <ErrorState message="Failed to load branches data." />;
return (
<div className="space-y-6">
<div className="bg-app-card p-6 rounded-lg shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<Network className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Manage Branches</h2>
<p className="text-sm text-text-muted">Add, edit, or disable branch locations.</p>
</div>
</div>
<button
onClick={handleAddClick}
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium"
>
<Plus size={18} className="mr-2" />
Add Branch
</button>
</div>
<BranchTable records={branches || []} onEdit={handleEditClick} onDelete={handleDeleteClick} />
{isModalOpen && (
<BranchFormModal
branch={editingBranch}
companies={activeCompanies}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
/>
)}
{deleteTarget && (
<ConfirmationModal
title="Delete Branch?"
message={`Are you sure you want to delete ${deleteTarget.name}? This action cannot be undone.`}
onConfirm={confirmDelete}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More