Compare commits

..

2 Commits

22 changed files with 655 additions and 35 deletions

View File

@ -0,0 +1,10 @@
[
{ "id": "L001", "leaveType": "Casual Leave", "applicationDate": "2026-07-10", "fromDate": "2026-07-04", "toDate": "2026-07-05", "leaveDays": 2, "status": "Approved", "reason": "Family function out of town." },
{ "id": "L002", "leaveType": "Optional Holiday", "applicationDate": "2026-07-15", "fromDate": "2026-07-14", "toDate": "2026-07-14", "leaveDays": 1, "status": "Pending", "reason": "Personal work." },
{ "id": "L003", "leaveType": "Casual Leave", "applicationDate": "2026-07-20", "fromDate": "2026-06-10", "toDate": "2026-06-11", "leaveDays": 2, "status": "Withdrawn", "reason": "Changed plans." },
{ "id": "L004", "leaveType": "Casual Leave", "applicationDate": "2026-07-22", "fromDate": "2026-06-20", "toDate": "2026-06-20", "leaveDays": 1, "status": "Approved", "reason": "Medical appointment." },
{ "id": "L005", "leaveType": "Optional Holiday", "applicationDate": "2026-07-18", "fromDate": "2026-07-01", "toDate": "2026-07-02", "leaveDays": 2, "status": "Pending", "reason": "Religious festival." },
{ "id": "L006", "leaveType": "Casual Leave", "applicationDate": "2026-07-02", "fromDate": "2026-05-15", "toDate": "2026-05-16", "leaveDays": 2, "status": "Approved", "reason": "Friend's wedding." },
{ "id": "L007", "leaveType": "Casual Leave", "applicationDate": "2026-07-10", "fromDate": "2026-04-25", "toDate": "2026-04-26", "leaveDays": 2, "status": "Approved", "reason": "House shifting." },
{ "id": "L008", "leaveType": "Optional Holiday", "applicationDate": "2026-07-22", "fromDate": "2026-07-21", "toDate": "2026-07-21", "leaveDays": 1, "status": "Pending", "reason": "Personal work." }
]

View File

@ -0,0 +1,4 @@
[
{ "leaveType": "Casual Leave", "credited": 10, "utilized": 8, "lapsed": 0, "balance": 2 },
{ "leaveType": "Optional Holiday", "credited": 4, "utilized": 3, "lapsed": 0, "balance": 1 }
]

View File

@ -1,10 +1,16 @@
// Simulates network latency and API fetching from public/mockApi/
// src/api/client.ts
// Generic GET request for any module
export const fetchMockData = async <T>(endpoint: string, delay = 500): Promise<T> => {
await new Promise((resolve) => setTimeout(resolve, delay));
const response = await fetch(`/mockApi/${endpoint}.json`);
if (!response.ok) {
throw new Error(`API Error: Failed to fetch ${endpoint}`);
}
const response = await fetch(`/mockApi/${endpoint}`);
if (!response.ok) throw new Error(`API Error: Failed to fetch ${endpoint}`);
return response.json() as Promise<T>;
};
// Generic POST request for any module
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,6 +1,6 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../store/store';
import { getSidebarItems } from '../../types/sidebarConfig';
import { getSidebarItems } from './types/sidebarConfig';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard, CalendarCheck, Plane, CheckCircle, BarChart3, ClipboardList,

View File

@ -1,4 +1,4 @@
import type { UserRole } from '../store/roleSlice';
import type { UserRole } from '../../../store/roleSlice';
export interface NavItem {
name: string;

View File

@ -3,15 +3,15 @@ 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';
import type { AttendanceRecord } from '../types/attendance';
// 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',
employee: 'attendance/employee_attendance.json',
manager: 'attendance/manager_attendance.json',
admin: 'attendance/admin_attendance.json',
superadmin: 'attendance/superadmin_attendance.json',
// Future real backend example:
// employee: '/api/v1/attendance/me',
// manager: '/api/v1/attendance/team',
@ -22,8 +22,9 @@ export const useAttendanceData = () => {
const role = useSelector((state: RootState) => state.role.currentRole);
return useQuery<AttendanceRecord[]>({
// Cache key includes the role so React Query caches them separately
queryKey: ['attendanceRecords', role],
queryKey: ['attendance', role],
queryFn: () => fetchMockData<AttendanceRecord[]>(ATTENDANCE_ENDPOINTS[role]),
});
};
};

View File

@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { AttendanceRecord, SortKey } from '../../../types/attendance';
import type { AttendanceRecord, SortKey } from '../types/attendance';
type SortDirection = 'ascending' | 'descending';

View File

@ -0,0 +1,27 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchMockData, postMockData } from '../../../api/client';
import type { LeaveSummary, LeaveRequestPayload, LeaveHistoryRecord } from '../types/leave';
export const useLeaveSummary = () => {
return useQuery<LeaveSummary[]>({
queryKey: ['leaveSummary', 'employee'],
queryFn: () => fetchMockData<LeaveSummary[]>('leave/employee_leave_summary.json'),
});
};
export const useApplyLeave = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: LeaveRequestPayload) => postMockData('leave/employee_leave_requests', payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leaveSummary', 'employee'] });
},
});
};
export const useLeaveHistory = () => {
return useQuery<LeaveHistoryRecord[]>({
queryKey: ['leaveHistory', 'employee'],
queryFn: () => fetchMockData<LeaveHistoryRecord[]>('leave/employee_leave_history.json'),
});
};

View File

@ -0,0 +1,133 @@
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;
};
// Fixed logic: +1 day, if Sunday -> Monday, with safe local formatting
const calculateResumptionDate = (to: string) => {
if (!to) return '';
const date = new Date(to + 'T00:00:00');
date.setDate(date.getDate() + 1); // Add 1 day
if (date.getDay() === 0) { // 0 is Sunday
date.setDate(date.getDate() + 1); // Make it Monday
}
// Format safely in local time
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);
const [selectedLeaveType, setSelectedLeaveType] = useState('');
const [formData, setFormData] = useState<LeaveRequestPayload>({
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 (!selectedLeaveType) { toast.error("Please select a leave type first."); return; }
setShowForm(true);
setFormData(prev => ({ ...prev, leaveType: selectedLeaveType }));
};
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);
setSelectedLeaveType('');
setFormData({ 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={selectedLeaveType} onChange={(e) => setSelectedLeaveType(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">
<option value="">Select Leave Type</option>
{summaryData.map((leave, idx) => (<option key={idx} value={leave.leaveType}>{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">{selectedLeaveType}</span>
<button type="button" onClick={() => setShowForm(false)} className="text-text-muted hover:text-text-primary 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

@ -0,0 +1,252 @@
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 './ViewLeaveModal';
import WithdrawLeaveModal from './WithdrawLeaveModal';
interface LeaveHistoryTableProps {
records: LeaveHistoryRecord[];
}
type SortDirection = 'ascending' | 'descending';
// Updated to handle empty strings safely
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) {
// Default to empty strings to show all records initially
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);
// Use an overrides object to handle instant UI updates for withdrawals
const [localOverrides, setLocalOverrides] = useState<Record<string, LeaveHistoryRecord>>({});
const combinedRecords = useMemo(() => {
return records.map(r => localOverrides[r.id] || r);
}, [records, localOverrides]);
// Filter specifically by applicationDate
const filteredData = useMemo(() => {
return combinedRecords.filter((record) => {
const isAfterFrom = fromDate ? record.applicationDate >= fromDate : true;
const isBeforeTo = toDate ? record.applicationDate <= toDate : true;
return isAfterFrom && isBeforeTo;
});
}, [combinedRecords, fromDate, toDate]);
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 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) => {
// Find the current record (either from original array or overrides)
const targetRecord = combinedRecords.find(r => r.id === id);
if (!targetRecord) return;
// Update local override for instant UI feedback - changes status, does NOT delete
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) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Withdrawn': return 'bg-absent-100 text-absent-700';
default: 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">My 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 === '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>
{/* Footer */}
<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>
{/* Render Separated Modals */}
{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

@ -0,0 +1,37 @@
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">My 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

@ -0,0 +1,65 @@
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) => {
switch (status) {
case 'Approved': return 'bg-present-100 text-present-700';
case 'Pending': return 'bg-late-100 text-late-700';
case 'Withdrawn': return 'bg-absent-100 text-absent-700';
default: 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-1">Leave Details</h3>
<p className="text-sm text-text-muted mb-6">Application ID: {record.id}</p>
<div className="space-y-4">
<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 === '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

@ -0,0 +1,32 @@
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

@ -0,0 +1,29 @@
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import LeaveSummaryTable from '../components/LeaveSummaryTable';
import ApplyLeaveForm from '../components/ApplyLeaveForm';
import LeaveHistoryTable from '../components/LeaveHistoryTable';
import { useLeaveSummary, useLeaveHistory } from '../api/useLeaveData';
export default function EmployeeLeave() {
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">
{/* Top Section: Summary & Apply Form */}
<div className="flex flex-col lg:flex-row gap-6">
{summaryData && <LeaveSummaryTable data={summaryData} />}
{summaryData && <ApplyLeaveForm summaryData={summaryData} />}
</div>
{/* Bottom Section: Leave History Table */}
{historyData && <LeaveHistoryTable records={historyData} />}
</div>
);
}

View File

@ -1,9 +1,10 @@
import { useSelector } from 'react-redux';
import type { RootState } from '../../../store/store';
import EmployeeLeave from '../pages/EmployeeLeave';
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>;
// Dummy components for Manager/Admin for now
const ManagerLeave = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Team Leave Calendar</h2></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></div>;
export default function LeaveRouter() {
const role = useSelector((state: RootState) => state.role.currentRole);
@ -11,7 +12,7 @@ export default function LeaveRouter() {
switch (role) {
case 'employee': return <EmployeeLeave />;
case 'manager': return <ManagerLeave />;
case 'admin': return <ManagerLeave />;
case 'admin':
case 'superadmin': return <AdminLeave />;
default: return <div>Unauthorized</div>;
}

View File

@ -0,0 +1,36 @@
export interface LeaveSummary {
leaveType: string;
credited: number;
utilized: number;
lapsed: number;
balance: number;
}
export type DayMode = 'Full Day' | 'First Half' | 'Second Half';
export interface LeaveRequestPayload {
leaveType: string;
fromDate: string;
fromDayMode: DayMode;
toDate: string;
toDayMode: DayMode;
leaveDays: number;
resumptionDate: string;
reason: string;
}
// New Type for Leave History
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;

View File

@ -1,14 +1 @@
export type UserRole = 'employee' | 'manager' | 'admin' | 'superadmin';
export type AttendanceStatus = 'Present' | 'WFH' | 'Absent' | 'Leave' | 'Mispunch' | 'Holiday' | 'Late' | 'Half Day';
export interface AttendanceRecord {
date: string;
employeeName?: string; // Optional for employee view, required for admin/manager
timeIn: string;
timeOut: string;
loggedHours: string;
status: AttendanceStatus;
}
export type SortKey = keyof AttendanceRecord;