Compare commits
2 Commits
1433c241e5
...
31fb8bba85
| Author | SHA1 | Date | |
|---|---|---|---|
| 31fb8bba85 | |||
| 303c7beffd |
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
# Stage 1: Build the React application
|
||||
FROM node:20-alpine as builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve the app with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove the default Nginx static assets and config
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy the build output from the builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy the custom Nginx configuration file
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
hrms-frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hrms-frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Volume mount the Nginx config file
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hrms-network
|
||||
|
||||
networks:
|
||||
hrms-network:
|
||||
driver: bridge
|
||||
19
nginx/default.conf
Normal file
19
nginx/default.conf
Normal file
@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# This is crucial for React Router to work on page refresh
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Optional: Cache static assets for better performance
|
||||
location ~* \.(?:css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-spinners": "^0.17.0",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2879,6 +2880,16 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-spinners": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.17.0.tgz",
|
||||
"integrity": "sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-spinners": "^0.17.0",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -12,5 +12,11 @@
|
||||
{ "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-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" }
|
||||
]
|
||||
@ -2,8 +2,8 @@ import { Provider } from 'react-redux';
|
||||
import { store } from './store/store';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import MainLayout from './components/MainLayout';
|
||||
import { BrowserRouter } from 'react-router-dom'; // <-- Import BrowserRouter
|
||||
import MainLayout from './components/layout/MainLayout';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@ -11,7 +11,6 @@ function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* Wrap MainLayout in BrowserRouter */}
|
||||
<BrowserRouter>
|
||||
<MainLayout />
|
||||
</BrowserRouter>
|
||||
|
||||
10
src/api/client.ts
Normal file
10
src/api/client.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// 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}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: Failed to fetch ${endpoint}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar } from 'lucide-react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import type { AttendanceRecord, SortKey } from '../types/attendance';
|
||||
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
@ -11,6 +11,38 @@ interface AttendanceRecordsTableProps {
|
||||
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) => {
|
||||
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; // +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
|
||||
};
|
||||
return priorities[status] || 99; // Unknown statuses go to the end
|
||||
};
|
||||
|
||||
export default function AttendanceRecordsTable({
|
||||
records,
|
||||
title = "Attendance Records",
|
||||
@ -18,19 +50,23 @@ export default function AttendanceRecordsTable({
|
||||
initialToDate
|
||||
}: AttendanceRecordsTableProps) {
|
||||
|
||||
// Default to current month if no initial dates are provided
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth();
|
||||
const defaultFrom = initialFromDate || new Date(currentYear, currentMonth, 1).toISOString().split('T')[0];
|
||||
const defaultTo = initialToDate || new Date(currentYear, currentMonth + 1, 0).toISOString().split('T')[0];
|
||||
// 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: 'descending'
|
||||
direction: 'ascending'
|
||||
});
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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]);
|
||||
@ -38,6 +74,16 @@ export default function AttendanceRecordsTable({
|
||||
const sortedData = useMemo(() => {
|
||||
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);
|
||||
if (priorityA < priorityB) return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||
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;
|
||||
@ -45,10 +91,58 @@ export default function AttendanceRecordsTable({
|
||||
return sortableData;
|
||||
}, [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);
|
||||
}
|
||||
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); // Reset page on sort
|
||||
};
|
||||
|
||||
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) => {
|
||||
@ -82,7 +176,7 @@ export default function AttendanceRecordsTable({
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
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>
|
||||
@ -92,7 +186,7 @@ export default function AttendanceRecordsTable({
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
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>
|
||||
@ -110,15 +204,15 @@ export default function AttendanceRecordsTable({
|
||||
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: string) => str.toUpperCase())} {getSortIcon(key)}
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} {getSortIcon(key)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-app-border">
|
||||
{sortedData.length > 0 ? (
|
||||
sortedData.map((record, index) => (
|
||||
{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-US', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
@ -143,6 +237,74 @@ export default function AttendanceRecordsTable({
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ErrorStateProps {
|
||||
message?: string;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export default function ErrorState({
|
||||
message = 'Something went wrong. Please try again later.',
|
||||
fullScreen = false
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-app-card rounded-lg shadow-sm text-absent-500 ${
|
||||
fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
|
||||
}`}>
|
||||
<AlertCircle size={24} />
|
||||
<span className="ml-3">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoaderProps {
|
||||
message?: string;
|
||||
size?: number;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export default function Loader({
|
||||
message = 'Loading...',
|
||||
size = 32,
|
||||
fullScreen = false
|
||||
}: LoaderProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center bg-app-card rounded-lg shadow-sm ${
|
||||
fullScreen ? 'h-screen w-screen fixed top-0 left-0 z-50' : 'h-64 w-full'
|
||||
}`}>
|
||||
<Loader2 className="animate-spin text-primary" size={size} />
|
||||
{message && <span className="mt-3 text-text-muted">{message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,25 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useIsFetching } from '@tanstack/react-query';
|
||||
import Sidebar from './Sidebar';
|
||||
import Navbar from './Navbar';
|
||||
import DashboardRouter from '../features/dashboard/DashboardRouter';
|
||||
import AttendanceRouter from '../features/attendance/AttendanceRouter';
|
||||
import LeaveRouter from '../features/leave/LeaveRouter';
|
||||
import ApprovalRouter from '../features/approval/ApprovalRouter';
|
||||
import AttendanceSummaryRouter from '../features/summary/AttendanceSummaryRouter';
|
||||
import LeaveSummaryRouter from '../features/summary/LeaveSummaryRouter';
|
||||
import ManageEmployeesRouter from '../features/manage/ManageEmployeesRouter';
|
||||
import ManageAdminsRouter from '../features/manage/ManageAdminsRouter';
|
||||
import PolicyRouter from '../features/policy/PolicyRouter';
|
||||
import DashboardRouter from '../../features/routes/DashboardRouter';
|
||||
import AttendanceRouter from '../../features/routes/AttendanceRouter';
|
||||
import LeaveRouter from '../../features/routes/LeaveRouter';
|
||||
import ApprovalRouter from '../../features/routes/ApprovalRouter';
|
||||
import AttendanceSummaryRouter from '../../features/routes/AttendanceSummaryRouter';
|
||||
import LeaveSummaryRouter from '../../features/routes/LeaveSummaryRouter';
|
||||
import ManageEmployeesRouter from '../../features/routes/ManageEmployeesRouter';
|
||||
import ManageAdminsRouter from '../../features/routes/ManageAdminsRouter';
|
||||
import PolicyRouter from '../../features/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} />
|
||||
<div className="flex-1 flex flex-col">
|
||||
|
||||
{/* 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">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardRouter />} />
|
||||
@ -31,6 +43,8 @@ export default function MainLayout() {
|
||||
<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>
|
||||
@ -1,6 +1,6 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState } from '../store/store';
|
||||
import { setRole, type UserRole } from '../store/roleSlice';
|
||||
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 } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -163,8 +163,8 @@ export default function Navbar() {
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className={`block w-full text-left px-4 py-2 text-sm capitalize hover:bg-app-muted ${currentRole === role
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-text-secondary'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../store/store';
|
||||
import { getSidebarItems } from '../types/sidebarConfig';
|
||||
import type { RootState } from '../../store/store';
|
||||
import { getSidebarItems } from '../../types/sidebarConfig';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, CalendarCheck, Plane, CheckCircle, BarChart3, ClipboardList,
|
||||
@ -59,8 +59,8 @@ export default function Sidebar({ isCollapsed, setIsCollapsed }: SidebarProps) {
|
||||
<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'
|
||||
? '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" />}
|
||||
23
src/components/ui/ErrorState.tsx
Normal file
23
src/components/ui/ErrorState.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ErrorStateProps {
|
||||
message?: string;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export default function ErrorState({
|
||||
message = 'Something went wrong. Please try again later.',
|
||||
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'
|
||||
}`}>
|
||||
{/* 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>
|
||||
<span className="text-sm font-medium text-text-secondary text-center max-w-xs">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/ui/Loader.tsx
Normal file
28
src/components/ui/Loader.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { FadeLoader } from 'react-spinners';
|
||||
|
||||
interface LoaderProps {
|
||||
message?: string;
|
||||
color?: string;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export default function Loader({
|
||||
message = 'Loading...',
|
||||
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'
|
||||
}`}>
|
||||
<FadeLoader
|
||||
color={color}
|
||||
height={15}
|
||||
width={5}
|
||||
radius={2}
|
||||
margin={2}
|
||||
/>
|
||||
{message && <span className="mt-6 text-sm font-medium text-text-muted">{message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import EmployeeAttendance from './EmployeeAttendance';
|
||||
|
||||
const ManagerAttendance = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Team Attendance</h2><p className="mt-2 text-text-muted">View your team's daily attendance logs.</p></div>;
|
||||
const AdminAttendance = () => <div className="bg-app-card p-6 rounded-lg shadow"><h2 className="text-2xl font-bold text-primary">Branch Attendance</h2><p className="mt-2 text-text-muted">Raw attendance logs for all employees.</p></div>;
|
||||
|
||||
export default function AttendanceRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
|
||||
switch (role) {
|
||||
case 'employee': return <EmployeeAttendance />;
|
||||
case 'manager': return <ManagerAttendance />;
|
||||
case 'admin':
|
||||
case 'superadmin': return <AdminAttendance />;
|
||||
default: return <div>Unauthorized</div>;
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import Calendar from '../../components/Calendar';
|
||||
import AttendanceRecordsTable from '../../components/AttendanceRecordsTable';
|
||||
import DailySummary from '../../components/DailySummary';
|
||||
import Loader from '../../components/Loader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import { useAttendanceData } from './useAttendanceData';
|
||||
|
||||
// Helper to get current system date in YYYY-MM-DD format
|
||||
const getTodayDate = () => {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export default function EmployeeAttendance() {
|
||||
// Initialize with the current system date
|
||||
const [selectedDate, setSelectedDate] = useState(getTodayDate());
|
||||
|
||||
// Fetch data using TanStack Query
|
||||
const { data: attendanceRecords, isLoading, isError } = useAttendanceData();
|
||||
|
||||
// Map array data to dictionary for the Calendar
|
||||
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]);
|
||||
|
||||
const selectedDayData = calendarData[selectedDate] || null;
|
||||
|
||||
// 1. Loading State
|
||||
if (isLoading) {
|
||||
return <Loader message="Loading attendance data..." />;
|
||||
}
|
||||
|
||||
// 2. Error State
|
||||
if (isError) {
|
||||
return <ErrorState message="Failed to load attendance data. Please try again later." />;
|
||||
}
|
||||
|
||||
// 3. Success State
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Top Section: Calendar and Daily Summary */}
|
||||
<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={selectedDayData} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Reusable Attendance Records Table */}
|
||||
{/* Removed hardcoded dates so it defaults to the current system month */}
|
||||
{attendanceRecords && (
|
||||
<AttendanceRecordsTable
|
||||
records={attendanceRecords}
|
||||
title="My Attendance Records"
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { AttendanceRecord } from '../../types/attendance';
|
||||
|
||||
export const useAttendanceData = () => {
|
||||
return useQuery<AttendanceRecord[]>({
|
||||
queryKey: ['attendanceRecords'],
|
||||
queryFn: async () => {
|
||||
// Simulate network delay (500ms)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const response = await fetch('/mockApi/attendance.json');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch attendance data');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -3,7 +3,7 @@ export default function AdminDashboard() {
|
||||
<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>
|
||||
46
src/features/roles/employee/EmployeeAttendance.tsx
Normal file
46
src/features/roles/employee/EmployeeAttendance.tsx
Normal 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'; // <-- Changed from FadeLoader to Loader
|
||||
import ErrorState from '../../../components/ui/ErrorState';
|
||||
import { useAttendanceData } from './hooks/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>
|
||||
);
|
||||
}
|
||||
10
src/features/roles/employee/hooks/useAttendanceData.ts
Normal file
10
src/features/roles/employee/hooks/useAttendanceData.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchMockData } from '../../../../api/client';
|
||||
import type { AttendanceRecord } from '../../../../types/attendance';
|
||||
|
||||
export const useAttendanceData = () => {
|
||||
return useQuery<AttendanceRecord[]>({
|
||||
queryKey: ['attendanceRecords', 'employee'],
|
||||
queryFn: () => fetchMockData<AttendanceRecord[]>('attendance'),
|
||||
});
|
||||
};
|
||||
17
src/features/routes/AttendanceRouter.tsx
Normal file
17
src/features/routes/AttendanceRouter.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import EmployeeAttendance from '../roles/employee/EmployeeAttendance';
|
||||
// import ManagerAttendance from '../../roles/manager/ManagerAttendance';
|
||||
// import AdminAttendance from '../roles/admin/AdminAttendance';
|
||||
|
||||
export default function AttendanceRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
|
||||
switch (role) {
|
||||
case 'employee': return <EmployeeAttendance />;
|
||||
// case 'manager': return <ManagerAttendance />;
|
||||
// case 'admin':
|
||||
// case 'superadmin': return <AdminAttendance />;
|
||||
default: return <div>Unauthorized</div>;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
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>;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import EmployeeDashboard from './EmployeeDashboard';
|
||||
import ManagerDashboard from './ManagerDashboard';
|
||||
import AdminDashboard from './AdminDashboard';
|
||||
import EmployeeDashboard from '../roles/employee/EmployeeDashboard';
|
||||
import ManagerDashboard from '../roles/manager/ManagerDashboard';
|
||||
import AdminDashboard from '../roles/admin/AdminDashboard';
|
||||
|
||||
export default function DashboardRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
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>;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import type { RootState } from '../../../store/store';
|
||||
|
||||
export default function ManageAdminsRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import type { RootState } from '../../../store/store';
|
||||
|
||||
export default function ManageEmployeesRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store/store';
|
||||
import type { RootState } from '../../../store/store';
|
||||
|
||||
export default function PolicyRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
13
src/types/index.ts
Normal file
13
src/types/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type UserRole = 'employee' | 'manager' | 'admin' | 'superadmin';
|
||||
|
||||
export type AttendanceStatus = 'Present' | 'WFH' | 'Absent' | 'Leave' | 'Mispunch' | 'Holiday' | 'Late' | 'Half Day';
|
||||
|
||||
export interface AttendanceRecord {
|
||||
date: string;
|
||||
timeIn: string;
|
||||
timeOut: string;
|
||||
loggedHours: string;
|
||||
status: AttendanceStatus;
|
||||
}
|
||||
|
||||
export type SortKey = keyof AttendanceRecord;
|
||||
Loading…
x
Reference in New Issue
Block a user