first commit
This commit is contained in:
commit
5958758b3f
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.db
|
||||||
151
README.md
Normal file
151
README.md
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# Axion - Unified HR/Payroll/Finance/Employee System
|
||||||
|
|
||||||
|
A comprehensive dashboard UI wireframe for a unified HR/Payroll/Finance/Employee system with scheduling and timecards. Built with React, TypeScript, and Tailwind CSS.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Authentication & Security
|
||||||
|
- **Login System**: Secure authentication with role-based access
|
||||||
|
- **Protected Routes**: All pages require authentication
|
||||||
|
- **Session Management**: Persistent login sessions
|
||||||
|
- **Demo Accounts**: Pre-configured accounts for all roles
|
||||||
|
|
||||||
|
### Role-Based Access
|
||||||
|
- **Employee**: Clock in/out, view timecards, schedule, documents, submit receipts
|
||||||
|
- **Manager**: All employee features plus team management, approvals, incident reports
|
||||||
|
- **HR**: Employee management, disciplinary actions, performance reviews, documents
|
||||||
|
- **Payroll**: Payroll runs, expenditures, invoices, OCR review, financial reports
|
||||||
|
- **Admin**: System settings, user management, automation workflows, audit logs
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
- **Create Users**: Add new employees/users with full details
|
||||||
|
- **Edit Users**: Update user information and roles
|
||||||
|
- **Delete Users**: Remove users from the system
|
||||||
|
- **Role Assignment**: Assign appropriate roles (Employee, Manager, HR, Payroll, Admin)
|
||||||
|
- **Search & Filter**: Quickly find users in the system
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- **Mock Database**: LocalStorage-based data persistence
|
||||||
|
- **CRUD Operations**: Full create, read, update, delete functionality
|
||||||
|
- **Data Persistence**: Data persists across browser sessions
|
||||||
|
- **Easy Migration**: Structure ready for backend API integration
|
||||||
|
|
||||||
|
### Key Screens
|
||||||
|
|
||||||
|
1. **Dashboard** - Role-specific home screen with relevant metrics and quick actions
|
||||||
|
2. **Clock In/Out** - Large, intuitive time tracking interface
|
||||||
|
3. **Timecards** - Weekly/monthly views with approval status
|
||||||
|
4. **Scheduling** - Visual calendar for shifts with drag-and-drop support
|
||||||
|
5. **Team Management** - Manager views for team timecards and schedules
|
||||||
|
6. **Receipts/Invoices** - Upload with OCR processing and review queue
|
||||||
|
7. **Disciplinary Actions** - Incident reporting and HR review workflow
|
||||||
|
8. **Employees** - Comprehensive employee management and details
|
||||||
|
9. **Payroll Runs** - Payroll processing with preview and approval
|
||||||
|
10. **OCR Review** - Queue for reviewing OCR-extracted receipt data
|
||||||
|
11. **System Settings** - Configuration for users, API keys, automation, branding
|
||||||
|
12. **Audit Logs** - Complete audit trail with filtering and detail views
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+ and npm
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open your browser to `http://localhost:5173`
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The built files will be in the `dist` directory.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ └── Layout/
|
||||||
|
│ ├── TopBar.tsx # Top navigation bar
|
||||||
|
│ ├── Sidebar.tsx # Role-filtered sidebar navigation
|
||||||
|
│ └── Layout.tsx # Main layout wrapper
|
||||||
|
├── context/
|
||||||
|
│ └── UserContext.tsx # User state and role management
|
||||||
|
├── pages/
|
||||||
|
│ ├── Dashboard.tsx # Role-specific dashboard
|
||||||
|
│ ├── ClockInOut.tsx # Time tracking interface
|
||||||
|
│ ├── Timecards.tsx # Employee timecard view
|
||||||
|
│ ├── Schedule.tsx # Employee schedule view
|
||||||
|
│ ├── TeamTimecards.tsx # Manager team timecards
|
||||||
|
│ ├── TeamSchedules.tsx # Manager team scheduling
|
||||||
|
│ ├── Receipts.tsx # Receipt/invoice upload
|
||||||
|
│ ├── DisciplinaryActions.tsx # Incident reports & HR review
|
||||||
|
│ ├── Employees.tsx # Employee management
|
||||||
|
│ ├── PayrollRuns.tsx # Payroll processing
|
||||||
|
│ ├── OCRReview.tsx # OCR review queue
|
||||||
|
│ ├── SystemSettings.tsx # Admin system settings
|
||||||
|
│ ├── AuditLogs.tsx # Audit log viewer
|
||||||
|
│ └── Placeholder.tsx # Placeholder for future pages
|
||||||
|
├── types.ts # TypeScript type definitions
|
||||||
|
├── App.tsx # Main app with routing
|
||||||
|
└── main.tsx # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login & Demo Accounts
|
||||||
|
|
||||||
|
The application includes a login screen with pre-configured demo accounts:
|
||||||
|
|
||||||
|
| Role | Email | Password |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Admin | admin@company.com | admin123 |
|
||||||
|
| HR | hr@company.com | hr123 |
|
||||||
|
| Payroll | payroll@company.com | payroll123 |
|
||||||
|
| Manager | manager@company.com | manager123 |
|
||||||
|
| Employee | employee@company.com | employee123 |
|
||||||
|
|
||||||
|
To test different roles, simply log out and log in with a different account.
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
Admin users can access the "User & Role Management" page from the sidebar to:
|
||||||
|
- Create new users
|
||||||
|
- Edit existing users
|
||||||
|
- Delete users
|
||||||
|
- Assign roles
|
||||||
|
- Search and filter users
|
||||||
|
|
||||||
|
All changes are persisted in localStorage and will remain after page refresh.
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
- **React 18** - UI library
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **React Router** - Navigation
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **Lucide React** - Icons
|
||||||
|
- **date-fns** - Date formatting
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is a UI wireframe/prototype. Backend integration would be needed for full functionality.
|
||||||
|
- All data is currently mock/placeholder data.
|
||||||
|
- The OCR processing, n8n workflows, and other integrations are represented in the UI but not implemented.
|
||||||
|
- Role-based navigation automatically filters menu items based on user role.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
127
TIMECLOCK.md
Normal file
127
TIMECLOCK.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Time Clock Page
|
||||||
|
|
||||||
|
A public-facing time clock interface for employees to sign in and out of their shifts.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Public Access**: No login required to view the page
|
||||||
|
- **Password-Only Authentication**: Employees only need their password to clock in/out
|
||||||
|
- **Visual Time Bars**: Each employee has a time bar showing 8 AM - 6 PM
|
||||||
|
- **Real-Time Status**:
|
||||||
|
- Red bar = Clocked out
|
||||||
|
- Green bar = Clocked in
|
||||||
|
- Yellow bar = On break
|
||||||
|
- **Schedule Integration**: Shows scheduled shift times and detects late arrivals/early departures
|
||||||
|
- **Auto-Refresh**: Status updates every 30 seconds
|
||||||
|
- **Timecard Integration**: Automatically creates/updates timecard entries
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Navigate to: `http://localhost:5173/timeclock`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **View Status**: All employees are displayed in a grid with their current status
|
||||||
|
2. **Clock In/Out**: Click on an employee's name
|
||||||
|
3. **Enter Password**: Enter the employee's password (not email)
|
||||||
|
4. **Confirm**: Click "Clock In" or "Clock Out" button
|
||||||
|
|
||||||
|
## Time Bar Visualization
|
||||||
|
|
||||||
|
- **Background**: Gray grid showing hourly markers (8:00 AM - 6:00 PM)
|
||||||
|
- **Scheduled Time**: Light blue overlay showing scheduled shift times
|
||||||
|
- **Current Status**: Colored bar (red/green/yellow) showing clock status
|
||||||
|
- **Current Time Indicator**: Black vertical line showing current time
|
||||||
|
- **Late Indicator**: Red marker if employee arrived late
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET `/api/timeclock/status`
|
||||||
|
Returns all employees with their current clock status.
|
||||||
|
|
||||||
|
**No authentication required**
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "employee@company.com",
|
||||||
|
"currentStatus": "clocked_in",
|
||||||
|
"clockInTime": "08:15",
|
||||||
|
"scheduledStart": "08:00",
|
||||||
|
"scheduledEnd": "17:00",
|
||||||
|
"isLate": true,
|
||||||
|
"leftEarly": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/timeclock/action`
|
||||||
|
Clock in/out action with password authentication.
|
||||||
|
|
||||||
|
**No authentication required**
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employeeId": "1",
|
||||||
|
"password": "employee123",
|
||||||
|
"action": "clock_in"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- `clock_in` - Clock in for the day
|
||||||
|
- `clock_out` - Clock out for the day
|
||||||
|
- `break_start` - Start break
|
||||||
|
- `break_end` - End break
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### With Schedule
|
||||||
|
- Automatically checks if employee has a scheduled shift for today
|
||||||
|
- Compares clock in time with scheduled start time
|
||||||
|
- Marks timecard as "Late arrival" if clocked in after scheduled time
|
||||||
|
- Marks timecard as "Left early" if clocked out before scheduled end time
|
||||||
|
|
||||||
|
### With Timecards
|
||||||
|
- Automatically creates a timecard entry when employee clocks in
|
||||||
|
- Updates timecard with clock out time
|
||||||
|
- Calculates total hours and overtime
|
||||||
|
- Deducts break time from total hours
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Password verification using bcrypt
|
||||||
|
- No JWT tokens required for viewing
|
||||||
|
- Only password needed for clock actions
|
||||||
|
- All actions are logged in timecards table
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Change Time Range
|
||||||
|
Edit the time bar range in `TimeClock.tsx`:
|
||||||
|
```typescript
|
||||||
|
const startMinutes = 8 * 60; // 8:00 AM
|
||||||
|
const endMinutes = 18 * 60; // 6:00 PM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Refresh Interval
|
||||||
|
Modify the interval in `TimeClock.tsx`:
|
||||||
|
```typescript
|
||||||
|
const interval = setInterval(fetchEmployees, 30000); // 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Colors
|
||||||
|
Modify the status colors:
|
||||||
|
```typescript
|
||||||
|
const getStatusColor = (employee: EmployeeStatus): string => {
|
||||||
|
if (employee.currentStatus === 'clocked_in') return 'bg-green-500';
|
||||||
|
if (employee.currentStatus === 'on_break') return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
57
TROUBLESHOOTING.md
Normal file
57
TROUBLESHOOTING.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Troubleshooting Blank Page Issue
|
||||||
|
|
||||||
|
If you're seeing a blank white page, try these steps:
|
||||||
|
|
||||||
|
## 1. Check the Correct Port
|
||||||
|
The dev server might be running on a different port. Check the terminal output - it should show:
|
||||||
|
```
|
||||||
|
➜ Local: http://localhost:5174/
|
||||||
|
```
|
||||||
|
Make sure you're accessing the correct port (might be 5173, 5174, or another port).
|
||||||
|
|
||||||
|
## 2. Check Browser Console
|
||||||
|
Open your browser's developer console (F12 or Right-click → Inspect → Console tab) and look for any red error messages. Common issues:
|
||||||
|
- Module not found errors
|
||||||
|
- Import errors
|
||||||
|
- TypeScript errors
|
||||||
|
|
||||||
|
## 3. Clear Browser Cache
|
||||||
|
Try a hard refresh:
|
||||||
|
- **Chrome/Edge**: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
|
||||||
|
- **Firefox**: Ctrl+F5 (Windows) or Cmd+Shift+R (Mac)
|
||||||
|
|
||||||
|
## 4. Restart Dev Server
|
||||||
|
Stop the dev server (Ctrl+C) and restart it:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Reinstall Dependencies
|
||||||
|
If the above doesn't work:
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Check for Build Errors
|
||||||
|
Run the build command to see if there are any TypeScript or build errors:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Cannot find module" errors
|
||||||
|
- Make sure all dependencies are installed: `npm install`
|
||||||
|
- Check that all import paths are correct
|
||||||
|
|
||||||
|
### Tailwind CSS not working
|
||||||
|
- Make sure `postcss.config.js` and `tailwind.config.js` exist
|
||||||
|
- Check that `index.css` imports Tailwind directives
|
||||||
|
|
||||||
|
### React Router issues
|
||||||
|
- Make sure you're accessing the root path `/`
|
||||||
|
- Check that all route components are properly exported
|
||||||
|
|
||||||
|
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Axion - HR/Payroll System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
223
n8n-workflows/README.md
Normal file
223
n8n-workflows/README.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# n8n Workflow: Receipt OCR Analysis
|
||||||
|
|
||||||
|
This workflow processes receipt images uploaded from the Axion HR system, extracts key information using OCR, and saves it to the backend database.
|
||||||
|
|
||||||
|
## Workflow Overview
|
||||||
|
|
||||||
|
1. **Webhook Receives Receipt** - Receives POST request with receipt image (base64) and user ID
|
||||||
|
2. **Extract Data** - Extracts image and user ID from request
|
||||||
|
3. **OCR API Call** - Sends image to OCR.space API for text extraction
|
||||||
|
4. **Parse Receipt Data** - Uses regex patterns to extract:
|
||||||
|
- Amount (total)
|
||||||
|
- Date
|
||||||
|
- Vendor name
|
||||||
|
- Tax amount
|
||||||
|
- Calculates confidence score
|
||||||
|
5. **Save to Backend** - Saves extracted data to backend API
|
||||||
|
6. **Respond Success** - Returns success response with receipt ID and extracted amount
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Import the Workflow
|
||||||
|
|
||||||
|
1. Open your n8n instance
|
||||||
|
2. Click "Workflows" → "Import from File"
|
||||||
|
3. Select `receipt-ocr-workflow.json`
|
||||||
|
4. The workflow will be imported with all nodes configured
|
||||||
|
|
||||||
|
### 2. Configure Environment Variables
|
||||||
|
|
||||||
|
Set these environment variables in your n8n instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OCR_API_KEY=your_ocr_space_api_key
|
||||||
|
BACKEND_API_URL=https://your-backend-api.com
|
||||||
|
BACKEND_API_KEY=your_backend_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Getting an OCR API Key:**
|
||||||
|
- Sign up at https://ocr.space/ocrapi
|
||||||
|
- Get your free API key (25,000 requests/month free)
|
||||||
|
- Or use alternative OCR services (Google Vision, AWS Textract, etc.)
|
||||||
|
|
||||||
|
### 3. Configure Webhook URL
|
||||||
|
|
||||||
|
1. Click on the "Webhook - Receipt Upload" node
|
||||||
|
2. Note the webhook URL (e.g., `https://your-n8n.com/webhook/receipt-upload`)
|
||||||
|
3. Update your frontend to POST to this URL
|
||||||
|
|
||||||
|
### 4. Update Backend API Endpoint
|
||||||
|
|
||||||
|
1. Click on the "Save to Backend" node
|
||||||
|
2. Update the URL to match your backend API endpoint
|
||||||
|
3. Ensure your backend expects this data structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "string",
|
||||||
|
"amount": "number",
|
||||||
|
"date": "string",
|
||||||
|
"vendor": "string",
|
||||||
|
"tax": "number",
|
||||||
|
"confidence": "number",
|
||||||
|
"status": "string",
|
||||||
|
"extractedText": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
Update your `Receipts.tsx` component to call the n8n webhook:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
// Convert file to base64
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = async () => {
|
||||||
|
const base64Image = reader.result as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('YOUR_N8N_WEBHOOK_URL', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: base64Image,
|
||||||
|
userId: currentUser.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Update UI with extracted data
|
||||||
|
setFormData({
|
||||||
|
amount: result.amount,
|
||||||
|
// ... other fields
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OCR processing failed:', error);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Customization
|
||||||
|
|
||||||
|
### Using Different OCR Service
|
||||||
|
|
||||||
|
Replace the "OCR API Call" node with your preferred service:
|
||||||
|
|
||||||
|
**Google Vision API:**
|
||||||
|
```javascript
|
||||||
|
// Use Google Vision API node or HTTP Request
|
||||||
|
POST https://vision.googleapis.com/v1/images:annotate
|
||||||
|
```
|
||||||
|
|
||||||
|
**AWS Textract:**
|
||||||
|
```javascript
|
||||||
|
// Use AWS Textract node
|
||||||
|
```
|
||||||
|
|
||||||
|
### Improving Amount Extraction
|
||||||
|
|
||||||
|
Modify the regex in "Parse Receipt Data" node:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// More robust amount regex
|
||||||
|
const amountRegex = /(?:total|amount|sum|balance|due|\\$|€|£|USD|EUR)\\s*:?\\s*([\\d,]+\\.[\\d]{2})/i;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Category Detection
|
||||||
|
|
||||||
|
Add a Code node after parsing to detect category:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const categoryKeywords = {
|
||||||
|
'Office Supplies': ['office', 'supplies', 'staples', 'paper'],
|
||||||
|
'Meals': ['restaurant', 'cafe', 'food', 'dining'],
|
||||||
|
'Transportation': ['uber', 'lyft', 'taxi', 'gas', 'fuel'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect category based on vendor name
|
||||||
|
let category = 'Other';
|
||||||
|
for (const [cat, keywords] of Object.entries(categoryKeywords)) {
|
||||||
|
if (keywords.some(kw => vendor?.toLowerCase().includes(kw))) {
|
||||||
|
category = cat;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test the Workflow
|
||||||
|
|
||||||
|
1. Use n8n's "Execute Workflow" button
|
||||||
|
2. Or send a test POST request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-n8n.com/webhook/receipt-upload \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"image": "base64_encoded_image_here",
|
||||||
|
"userId": "test-user-123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"receiptId": "receipt-123",
|
||||||
|
"amount": 45.99,
|
||||||
|
"confidence": 0.85,
|
||||||
|
"status": "needs_review"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### OCR Not Extracting Amount
|
||||||
|
|
||||||
|
- Check OCR API key is valid
|
||||||
|
- Verify image quality (clear, readable text)
|
||||||
|
- Adjust regex patterns in "Parse Receipt Data" node
|
||||||
|
- Check OCR API response in node output
|
||||||
|
|
||||||
|
### Backend Save Failing
|
||||||
|
|
||||||
|
- Verify backend API URL is correct
|
||||||
|
- Check API authentication headers
|
||||||
|
- Ensure backend endpoint accepts the data structure
|
||||||
|
- Check n8n execution logs for errors
|
||||||
|
|
||||||
|
### Low Confidence Scores
|
||||||
|
|
||||||
|
- Improve image quality before upload
|
||||||
|
- Adjust regex patterns to match your receipt format
|
||||||
|
- Add more extraction patterns for different receipt types
|
||||||
|
- Consider using ML-based extraction for better accuracy
|
||||||
|
|
||||||
|
## Alternative OCR Services
|
||||||
|
|
||||||
|
If OCR.space doesn't meet your needs:
|
||||||
|
|
||||||
|
1. **Google Cloud Vision API** - High accuracy, pay-per-use
|
||||||
|
2. **AWS Textract** - Good for structured documents
|
||||||
|
3. **Azure Computer Vision** - Microsoft's OCR service
|
||||||
|
4. **Tesseract.js** - Open source, runs locally
|
||||||
|
5. **ABBYY FineReader** - Enterprise-grade OCR
|
||||||
|
|
||||||
|
Update the "OCR API Call" node accordingly.
|
||||||
|
|
||||||
|
|
||||||
228
n8n-workflows/receipt-ocr-workflow.json
Normal file
228
n8n-workflows/receipt-ocr-workflow.json
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
{
|
||||||
|
"name": "Receipt OCR Analysis",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "receipt-upload",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "webhook-receipt-upload",
|
||||||
|
"name": "Webhook - Receipt Upload",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [250, 300],
|
||||||
|
"webhookId": "receipt-upload-webhook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "extract-image",
|
||||||
|
"name": "image",
|
||||||
|
"value": "={{ $json.body.image }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "extract-user-id",
|
||||||
|
"name": "userId",
|
||||||
|
"value": "={{ $json.body.userId }}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "extract-data",
|
||||||
|
"name": "Extract Receipt Data",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [450, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer {{ $env.OCR_API_KEY }}"
|
||||||
|
},
|
||||||
|
"requestMethod": "POST",
|
||||||
|
"url": "https://api.ocr.space/parse/image",
|
||||||
|
"sendBody": true,
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "apikey",
|
||||||
|
"value": "={{ $env.OCR_API_KEY }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "base64Image",
|
||||||
|
"value": "={{ $json.image }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"value": "eng"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isOverlayRequired",
|
||||||
|
"value": "false"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "ocr-api-call",
|
||||||
|
"name": "OCR API Call",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [650, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Extract text from OCR response\nconst ocrResponse = $input.item.json;\nconst parsedText = ocrResponse.ParsedResults?.[0]?.ParsedText || '';\n\n// Regular expressions to extract receipt data\nconst amountRegex = /(?:total|amount|sum|\\$|€|£)\\s*:?\\s*([\\d,]+\\.[\\d]{2})/i;\nconst dateRegex = /(\\d{1,2}[\\/\\-]\\d{1,2}[\\/\\-]\\d{2,4})/;\nconst vendorRegex = /^([A-Z][A-Za-z\\s&]+?)(?:\\s|$)/m;\nconst taxRegex = /(?:tax|vat|gst)\\s*:?\\s*([\\d,]+\\.[\\d]{2})/i;\n\n// Extract amount\nlet amount = null;\nconst amountMatch = parsedText.match(amountRegex);\nif (amountMatch) {\n amount = parseFloat(amountMatch[1].replace(/,/g, ''));\n}\n\n// Extract date\nlet date = null;\nconst dateMatch = parsedText.match(dateRegex);\nif (dateMatch) {\n date = dateMatch[1];\n}\n\n// Extract vendor (first line or company name)\nlet vendor = null;\nconst vendorMatch = parsedText.match(vendorRegex);\nif (vendorMatch) {\n vendor = vendorMatch[1].trim();\n}\n\n// Extract tax\nlet tax = null;\nconst taxMatch = parsedText.match(taxRegex);\nif (taxMatch) {\n tax = parseFloat(taxMatch[1].replace(/,/g, ''));\n}\n\n// Calculate confidence score based on extracted data\nlet confidence = 0;\nif (amount) confidence += 0.4;\nif (date) confidence += 0.2;\nif (vendor) confidence += 0.2;\nif (tax) confidence += 0.2;\n\nreturn {\n json: {\n userId: $input.item.json.userId,\n originalImage: $input.item.json.image,\n extractedText: parsedText,\n amount: amount,\n date: date,\n vendor: vendor,\n tax: tax || 0,\n confidence: confidence,\n status: confidence >= 0.6 ? 'needs_review' : 'pending',\n ocrRawResponse: ocrResponse\n }\n};"
|
||||||
|
},
|
||||||
|
"id": "parse-receipt-data",
|
||||||
|
"name": "Parse Receipt Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [850, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "Bearer {{ $env.BACKEND_API_KEY }}"
|
||||||
|
},
|
||||||
|
"requestMethod": "POST",
|
||||||
|
"url": "={{ $env.BACKEND_API_URL }}/api/receipts",
|
||||||
|
"sendBody": true,
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"value": "={{ $json.userId }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount",
|
||||||
|
"value": "={{ $json.amount }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "date",
|
||||||
|
"value": "={{ $json.date }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vendor",
|
||||||
|
"value": "={{ $json.vendor }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tax",
|
||||||
|
"value": "={{ $json.tax }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "confidence",
|
||||||
|
"value": "={{ $json.confidence }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"value": "={{ $json.status }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "extractedText",
|
||||||
|
"value": "={{ $json.extractedText }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "save-to-backend",
|
||||||
|
"name": "Save to Backend",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1050, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { \"success\": true, \"receiptId\": $json.id, \"amount\": $json.amount, \"confidence\": $json.confidence, \"status\": $json.status } }}"
|
||||||
|
},
|
||||||
|
"id": "respond-success",
|
||||||
|
"name": "Respond Success",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1250, 300]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Webhook - Receipt Upload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract Receipt Data",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract Receipt Data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "OCR API Call",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OCR API Call": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse Receipt Data",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse Receipt Data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Save to Backend",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Save to Backend": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Respond Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
4220
package-lock.json
generated
Normal file
4220
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "axion-hr-system",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Unified HR/Payroll/Finance/Employee system with scheduling and timecards",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"date-fns": "^2.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
8
postcss.config.js
Normal file
8
postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
193
server/README.md
Normal file
193
server/README.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Axion Backend API
|
||||||
|
|
||||||
|
Backend server for the Axion HR/Payroll System with SQLite database.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- SQLite database with comprehensive schema
|
||||||
|
- RESTful API endpoints
|
||||||
|
- JWT authentication
|
||||||
|
- Role-based access control
|
||||||
|
- CORS enabled for frontend integration
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The database includes tables for:
|
||||||
|
- Users (authentication)
|
||||||
|
- Employees (extended employee info)
|
||||||
|
- Timecards
|
||||||
|
- Shifts (scheduling)
|
||||||
|
- Disciplinary Actions
|
||||||
|
- Receipts/Invoices
|
||||||
|
- Payroll Runs
|
||||||
|
- Payroll Line Items
|
||||||
|
- Audit Logs
|
||||||
|
- Performance Reviews
|
||||||
|
- Documents
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create the database file at `server/data/axion.db`
|
||||||
|
- Create all tables
|
||||||
|
- Insert default users (admin, hr, payroll, manager, employee)
|
||||||
|
|
||||||
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
Create a `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set:
|
||||||
|
- `PORT` - Server port (default: 3001)
|
||||||
|
- `JWT_SECRET` - Secret key for JWT tokens (change in production!)
|
||||||
|
|
||||||
|
### 4. Start Server
|
||||||
|
|
||||||
|
Development mode (with auto-reload):
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Production mode:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will run on `http://localhost:3001`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `POST /api/auth/login` - Login with email/password
|
||||||
|
- `GET /api/auth/me` - Get current user info
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
- `GET /api/users` - Get all users (admin/hr only)
|
||||||
|
- `GET /api/users/:id` - Get user by ID
|
||||||
|
- `POST /api/users` - Create user (admin only)
|
||||||
|
- `PUT /api/users/:id` - Update user (admin/hr only)
|
||||||
|
- `DELETE /api/users/:id` - Delete user (admin only)
|
||||||
|
|
||||||
|
### Receipts
|
||||||
|
|
||||||
|
- `GET /api/receipts` - Get receipts (filtered by user)
|
||||||
|
- `GET /api/receipts/:id` - Get receipt by ID
|
||||||
|
- `POST /api/receipts` - Create receipt (from OCR)
|
||||||
|
- `PUT /api/receipts/:id` - Update receipt
|
||||||
|
- `DELETE /api/receipts/:id` - Delete receipt
|
||||||
|
|
||||||
|
### Timecards
|
||||||
|
|
||||||
|
- `GET /api/timecards` - Get timecards
|
||||||
|
- `POST /api/timecards` - Create timecard
|
||||||
|
- `PATCH /api/timecards/:id/status` - Update timecard status
|
||||||
|
|
||||||
|
## Default Users
|
||||||
|
|
||||||
|
After initialization, these users are available:
|
||||||
|
|
||||||
|
| Email | Password | Role |
|
||||||
|
|-------|----------|------|
|
||||||
|
| admin@company.com | admin123 | admin |
|
||||||
|
| hr@company.com | hr123 | hr |
|
||||||
|
| payroll@company.com | payroll123 | payroll |
|
||||||
|
| manager@company.com | manager123 | manager |
|
||||||
|
| employee@company.com | employee123 | employee |
|
||||||
|
|
||||||
|
## Database Location
|
||||||
|
|
||||||
|
The SQLite database file is stored at:
|
||||||
|
```
|
||||||
|
server/data/axion.db
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is gitignored. To backup, copy this file.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
To add new tables or modify schema:
|
||||||
|
1. Update `src/database/schema.sql`
|
||||||
|
2. Run `npm run init-db` (this will recreate the database)
|
||||||
|
|
||||||
|
For production, use proper migration tools.
|
||||||
|
|
||||||
|
### Adding New Routes
|
||||||
|
|
||||||
|
1. Create route file in `src/routes/`
|
||||||
|
2. Import and use in `src/server.js`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```javascript
|
||||||
|
import newRoutes from './routes/new.js';
|
||||||
|
app.use('/api/new', newRoutes);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **Change JWT_SECRET** - Use a strong, random secret
|
||||||
|
2. **Use PostgreSQL/MySQL** - SQLite is fine for development, but use a proper database for production
|
||||||
|
3. **Add rate limiting** - Prevent abuse
|
||||||
|
4. **Enable HTTPS** - Use reverse proxy (nginx) with SSL
|
||||||
|
5. **Database backups** - Set up regular backups
|
||||||
|
6. **Environment variables** - Never commit `.env` file
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
Update your frontend to use the API:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_URL = 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, user } = await response.json();
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
// Authenticated requests
|
||||||
|
const usersResponse = await fetch(`${API_URL}/users`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database locked error
|
||||||
|
- SQLite uses WAL mode for better concurrency
|
||||||
|
- If issues persist, check file permissions
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
- Change PORT in `.env`
|
||||||
|
- Or kill the process using port 3001
|
||||||
|
|
||||||
|
### Module not found
|
||||||
|
- Run `npm install` again
|
||||||
|
- Check Node.js version (requires Node 18+)
|
||||||
|
|
||||||
|
|
||||||
2764
server/package-lock.json
generated
Normal file
2764
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
server/package.json
Normal file
27
server/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "axion-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API for Axion HR/Payroll System",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"init-db": "node src/database/init.js",
|
||||||
|
"migrate": "node src/database/migrate.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
server/src/database/db.js
Normal file
26
server/src/database/db.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const dbPath = join(__dirname, '../../data/axion.db');
|
||||||
|
|
||||||
|
// Create database connection with promise wrapper
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error opening database:', err);
|
||||||
|
} else {
|
||||||
|
// Enable foreign keys
|
||||||
|
db.run('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Promisify methods
|
||||||
|
db.runAsync = promisify(db.run.bind(db));
|
||||||
|
db.getAsync = promisify(db.get.bind(db));
|
||||||
|
db.allAsync = promisify(db.all.bind(db));
|
||||||
|
|
||||||
|
export default db;
|
||||||
144
server/src/database/init.js
Normal file
144
server/src/database/init.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { readFileSync, mkdirSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = join(__dirname, '../../data');
|
||||||
|
try {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Directory might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = join(dataDir, 'axion.db');
|
||||||
|
|
||||||
|
console.log('Initializing database...');
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
db.run('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
// Read and execute schema
|
||||||
|
const schemaPath = join(__dirname, 'schema.sql');
|
||||||
|
const schema = readFileSync(schemaPath, 'utf-8');
|
||||||
|
|
||||||
|
db.exec(schema, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error executing schema:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database schema created successfully.');
|
||||||
|
|
||||||
|
// Insert default users
|
||||||
|
insertDefaultUsers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function insertDefaultUsers() {
|
||||||
|
const defaultUsers = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
password: 'admin123',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: 'admin'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
email: 'hr@company.com',
|
||||||
|
password: 'hr123',
|
||||||
|
name: 'HR Manager',
|
||||||
|
role: 'hr'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
email: 'payroll@company.com',
|
||||||
|
password: 'payroll123',
|
||||||
|
name: 'Payroll Admin',
|
||||||
|
role: 'payroll'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
email: 'manager@company.com',
|
||||||
|
password: 'manager123',
|
||||||
|
name: 'Team Manager',
|
||||||
|
role: 'manager'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
email: 'employee@company.com',
|
||||||
|
password: 'employee123',
|
||||||
|
name: 'John Doe',
|
||||||
|
role: 'employee'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
const total = defaultUsers.length;
|
||||||
|
|
||||||
|
defaultUsers.forEach((user) => {
|
||||||
|
const passwordHash = bcrypt.hashSync(user.password, 10);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO users (id, email, password_hash, name, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[user.id, user.email, passwordHash, user.name, user.role],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Error inserting user ${user.email}:`, err);
|
||||||
|
} else {
|
||||||
|
// Create employee records for non-admin users
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const jobTitles = {
|
||||||
|
'hr': 'HR Manager',
|
||||||
|
'payroll': 'Payroll Admin',
|
||||||
|
'manager': 'Team Manager',
|
||||||
|
'employee': 'Cashier'
|
||||||
|
};
|
||||||
|
|
||||||
|
const departments = {
|
||||||
|
'hr': 'Human Resources',
|
||||||
|
'payroll': 'Finance',
|
||||||
|
'manager': 'Retail',
|
||||||
|
'employee': 'Retail'
|
||||||
|
};
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO employees (id, user_id, job_title, department, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'Active')`,
|
||||||
|
[`emp-${user.id}`, user.id, jobTitles[user.role] || 'Employee', departments[user.role] || 'General'],
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error(`Error inserting employee for ${user.email}:`, err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completed++;
|
||||||
|
if (completed === total) {
|
||||||
|
console.log('Default users created:');
|
||||||
|
console.log(' Admin: admin@company.com / admin123');
|
||||||
|
console.log(' HR: hr@company.com / hr123');
|
||||||
|
console.log(' Payroll: payroll@company.com / payroll123');
|
||||||
|
console.log(' Manager: manager@company.com / manager123');
|
||||||
|
console.log(' Employee: employee@company.com / employee123');
|
||||||
|
console.log('Database initialization complete!');
|
||||||
|
console.log(`Database file: ${dbPath}`);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
182
server/src/database/schema.sql
Normal file
182
server/src/database/schema.sql
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
-- Axion HR/Payroll System Database Schema
|
||||||
|
|
||||||
|
-- Users table (for authentication and user management)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('employee', 'manager', 'hr', 'payroll', 'admin')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Employees table (extended employee information)
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT UNIQUE NOT NULL,
|
||||||
|
job_title TEXT,
|
||||||
|
department TEXT,
|
||||||
|
manager_id TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
address TEXT,
|
||||||
|
hire_date DATE,
|
||||||
|
status TEXT DEFAULT 'Active' CHECK(status IN ('Active', 'Inactive', 'On Leave')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Timecards table
|
||||||
|
CREATE TABLE IF NOT EXISTS timecards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
employee_id TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
clock_in DATETIME,
|
||||||
|
clock_out DATETIME,
|
||||||
|
break_start DATETIME,
|
||||||
|
break_end DATETIME,
|
||||||
|
total_hours REAL DEFAULT 0,
|
||||||
|
overtime_hours REAL DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'flagged')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Shifts table (scheduling)
|
||||||
|
CREATE TABLE IF NOT EXISTS shifts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
employee_id TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
location TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Disciplinary actions table
|
||||||
|
CREATE TABLE IF NOT EXISTS disciplinary_actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
employee_id TEXT NOT NULL,
|
||||||
|
reported_by TEXT NOT NULL,
|
||||||
|
incident_date DATE NOT NULL,
|
||||||
|
details TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'pending_approval', 'finalized')),
|
||||||
|
document_url TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (reported_by) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Receipts/Invoices table
|
||||||
|
CREATE TABLE IF NOT EXISTS receipts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
vendor TEXT,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
date DATE,
|
||||||
|
tax REAL DEFAULT 0,
|
||||||
|
category TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'needs_review', 'approved', 'rejected')),
|
||||||
|
ocr_confidence REAL,
|
||||||
|
image_url TEXT,
|
||||||
|
extracted_text TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payroll runs table
|
||||||
|
CREATE TABLE IF NOT EXISTS payroll_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
pay_period_start DATE NOT NULL,
|
||||||
|
pay_period_end DATE NOT NULL,
|
||||||
|
status TEXT DEFAULT 'preview' CHECK(status IN ('preview', 'pending', 'completed')),
|
||||||
|
total_hours REAL DEFAULT 0,
|
||||||
|
total_gross REAL DEFAULT 0,
|
||||||
|
total_net REAL DEFAULT 0,
|
||||||
|
errors INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payroll line items (individual employee payroll entries)
|
||||||
|
CREATE TABLE IF NOT EXISTS payroll_line_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
payroll_run_id TEXT NOT NULL,
|
||||||
|
employee_id TEXT NOT NULL,
|
||||||
|
hours REAL NOT NULL,
|
||||||
|
rate REAL NOT NULL,
|
||||||
|
gross_pay REAL NOT NULL,
|
||||||
|
deductions REAL DEFAULT 0,
|
||||||
|
taxes REAL DEFAULT 0,
|
||||||
|
net_pay REAL NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (payroll_run_id) REFERENCES payroll_runs(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit logs table
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
record_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
severity TEXT DEFAULT 'low' CHECK(severity IN ('low', 'medium', 'high')),
|
||||||
|
before_data TEXT,
|
||||||
|
after_data TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance reviews table
|
||||||
|
CREATE TABLE IF NOT EXISTS performance_reviews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
employee_id TEXT NOT NULL,
|
||||||
|
reviewer_id TEXT NOT NULL,
|
||||||
|
review_date DATE NOT NULL,
|
||||||
|
rating INTEGER CHECK(rating >= 1 AND rating <= 5),
|
||||||
|
comments TEXT,
|
||||||
|
goals TEXT,
|
||||||
|
status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'submitted', 'completed')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Documents table
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
employee_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
file_url TEXT NOT NULL,
|
||||||
|
expiry_date DATE,
|
||||||
|
uploaded_by TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timecards_employee_date ON timecards(employee_id, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shifts_employee_date ON shifts(employee_id, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_status ON receipts(user_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_user ON employees(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payroll_line_items_run ON payroll_line_items(payroll_run_id);
|
||||||
|
|
||||||
36
server/src/middleware/auth.js
Normal file
36
server/src/middleware/auth.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
|
|
||||||
|
export const authenticateToken = (req, res, next) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Access token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireRole = (...roles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
74
server/src/routes/auth.js
Normal file
74
server/src/routes/auth.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import db from '../database/db.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.getAsync('SELECT * FROM users WHERE email = ?', email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
router.get('/me', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
const user = await db.getAsync('SELECT id, email, name, role FROM users WHERE id = ?', decoded.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
167
server/src/routes/receipts.js
Normal file
167
server/src/routes/receipts.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import db from '../database/db.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all receipts (filtered by user unless admin/payroll)
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let query = 'SELECT * FROM receipts';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (!['admin', 'payroll'].includes(req.user.role)) {
|
||||||
|
query += ' WHERE user_id = ?';
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const receipts = await db.allAsync(query, ...params);
|
||||||
|
res.json(receipts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching receipts:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get receipt by ID
|
||||||
|
router.get('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const receipt = await db.getAsync('SELECT * FROM receipts WHERE id = ?', id);
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
return res.status(404).json({ error: 'Receipt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (receipt.user_id !== req.user.id && !['admin', 'payroll'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(receipt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching receipt:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create receipt (from OCR processing)
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
vendor,
|
||||||
|
amount,
|
||||||
|
date,
|
||||||
|
tax,
|
||||||
|
category,
|
||||||
|
notes,
|
||||||
|
status,
|
||||||
|
ocrConfidence,
|
||||||
|
imageUrl,
|
||||||
|
extractedText
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
return res.status(400).json({ error: 'Amount is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const receipt = {
|
||||||
|
id,
|
||||||
|
user_id: req.user.id,
|
||||||
|
vendor: vendor || null,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
date: date || new Date().toISOString().split('T')[0],
|
||||||
|
tax: tax ? parseFloat(tax) : 0,
|
||||||
|
category: category || null,
|
||||||
|
notes: notes || null,
|
||||||
|
status: status || 'pending',
|
||||||
|
ocr_confidence: ocrConfidence || null,
|
||||||
|
image_url: imageUrl || null,
|
||||||
|
extracted_text: extractedText || null
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO receipts (
|
||||||
|
id, user_id, vendor, amount, date, tax, category, notes,
|
||||||
|
status, ocr_confidence, image_url, extracted_text
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
receipt.id, receipt.user_id, receipt.vendor, receipt.amount,
|
||||||
|
receipt.date, receipt.tax, receipt.category, receipt.notes,
|
||||||
|
receipt.status, receipt.ocr_confidence, receipt.image_url, receipt.extracted_text
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(receipt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating receipt:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update receipt
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const receipt = await db.getAsync('SELECT * FROM receipts WHERE id = ?', id);
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
return res.status(404).json({ error: 'Receipt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (receipt.user_id !== req.user.id && !['admin', 'payroll'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
vendor,
|
||||||
|
amount,
|
||||||
|
date,
|
||||||
|
tax,
|
||||||
|
category,
|
||||||
|
notes,
|
||||||
|
status
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE receipts
|
||||||
|
SET vendor = ?, amount = ?, date = ?, tax = ?, category = ?,
|
||||||
|
notes = ?, status = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, vendor, amount, date, tax, category, notes, status, id);
|
||||||
|
|
||||||
|
res.json({ message: 'Receipt updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating receipt:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete receipt
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const receipt = await db.getAsync('SELECT * FROM receipts WHERE id = ?', id);
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
return res.status(404).json({ error: 'Receipt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (receipt.user_id !== req.user.id && !['admin', 'payroll'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync('DELETE FROM receipts WHERE id = ?', id);
|
||||||
|
res.json({ message: 'Receipt deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting receipt:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
116
server/src/routes/timecards.js
Normal file
116
server/src/routes/timecards.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import db from '../database/db.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get timecards
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { employeeId, startDate, endDate } = req.query;
|
||||||
|
let query = 'SELECT * FROM timecards WHERE 1=1';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
// Filter by employee unless admin/manager/hr
|
||||||
|
if (!['admin', 'manager', 'hr'].includes(req.user.role)) {
|
||||||
|
query += ' AND employee_id = ?';
|
||||||
|
params.push(req.user.id);
|
||||||
|
} else if (employeeId) {
|
||||||
|
query += ' AND employee_id = ?';
|
||||||
|
params.push(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += ' AND date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += ' AND date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY date DESC';
|
||||||
|
|
||||||
|
const timecards = await db.allAsync(query, ...params);
|
||||||
|
res.json(timecards);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching timecards:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create/Update timecard
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
date,
|
||||||
|
clockIn,
|
||||||
|
clockOut,
|
||||||
|
breakStart,
|
||||||
|
breakEnd,
|
||||||
|
notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const employeeId = req.body.employeeId || req.user.id;
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (employeeId !== req.user.id && !['admin', 'manager', 'hr'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hours
|
||||||
|
let totalHours = 0;
|
||||||
|
if (clockIn && clockOut) {
|
||||||
|
const start = new Date(clockIn);
|
||||||
|
const end = new Date(clockOut);
|
||||||
|
const diff = (end - start) / (1000 * 60 * 60); // hours
|
||||||
|
|
||||||
|
let breakTime = 0;
|
||||||
|
if (breakStart && breakEnd) {
|
||||||
|
const breakStartTime = new Date(breakStart);
|
||||||
|
const breakEndTime = new Date(breakEnd);
|
||||||
|
breakTime = (breakEndTime - breakStartTime) / (1000 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalHours = diff - breakTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overtimeHours = totalHours > 8 ? totalHours - 8 : 0;
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO timecards (
|
||||||
|
id, employee_id, date, clock_in, clock_out, break_start, break_end,
|
||||||
|
total_hours, overtime_hours, notes, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||||
|
`, id, employeeId, date, clockIn, clockOut, breakStart, breakEnd,
|
||||||
|
totalHours, overtimeHours, notes);
|
||||||
|
|
||||||
|
res.status(201).json({ id, message: 'Timecard created successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating timecard:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update timecard status (approve/flag)
|
||||||
|
router.patch('/:id/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!['admin', 'manager', 'hr'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync('UPDATE timecards SET status = ? WHERE id = ?', status, id);
|
||||||
|
res.json({ message: 'Timecard status updated' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating timecard:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
310
server/src/routes/timeclock.js
Normal file
310
server/src/routes/timeclock.js
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import db from '../database/db.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all employees with current status (PUBLIC - no auth required)
|
||||||
|
router.get('/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
const currentTime = now.toTimeString().split(' ')[0].substring(0, 5); // HH:mm format
|
||||||
|
|
||||||
|
// Get all active employees with their current status
|
||||||
|
const employees = await db.allAsync(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
e.job_title,
|
||||||
|
s.start_time as scheduled_start,
|
||||||
|
s.end_time as scheduled_end
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN employees e ON u.id = e.user_id
|
||||||
|
LEFT JOIN shifts s ON u.id = s.employee_id AND s.date = ?
|
||||||
|
WHERE e.status = 'Active' AND u.role != 'admin'
|
||||||
|
ORDER BY u.name
|
||||||
|
`, today);
|
||||||
|
|
||||||
|
// Get current timecard status for each employee
|
||||||
|
const statuses = await Promise.all(employees.map(async (emp) => {
|
||||||
|
const timecard = await db.getAsync(`
|
||||||
|
SELECT clock_in, clock_out, break_start, break_end, status
|
||||||
|
FROM timecards
|
||||||
|
WHERE employee_id = ? AND date = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, emp.id, today);
|
||||||
|
|
||||||
|
let currentStatus = 'clocked_out';
|
||||||
|
let clockInTime = null;
|
||||||
|
let clockOutTime = null;
|
||||||
|
let breakStartTime = null;
|
||||||
|
let isLate = false;
|
||||||
|
let leftEarly = false;
|
||||||
|
|
||||||
|
if (timecard) {
|
||||||
|
clockInTime = timecard.clock_in;
|
||||||
|
clockOutTime = timecard.clock_out;
|
||||||
|
|
||||||
|
// Status is only "clocked_in" or "on_break" if they clocked in but haven't clocked out yet
|
||||||
|
if (timecard.clock_in && !timecard.clock_out) {
|
||||||
|
if (timecard.break_start && !timecard.break_end) {
|
||||||
|
currentStatus = 'on_break';
|
||||||
|
breakStartTime = timecard.break_start;
|
||||||
|
} else {
|
||||||
|
currentStatus = 'clocked_in';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If they've clocked out, status is clocked_out (even if they clocked in earlier)
|
||||||
|
currentStatus = 'clocked_out';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if late (clocked in after scheduled start)
|
||||||
|
if (emp.scheduled_start && clockInTime) {
|
||||||
|
const scheduledTime = emp.scheduled_start;
|
||||||
|
const clockInTimeOnly = clockInTime.includes(' ')
|
||||||
|
? clockInTime.split(' ')[1]?.substring(0, 5)
|
||||||
|
: clockInTime.substring(0, 5);
|
||||||
|
if (clockInTimeOnly && clockInTimeOnly > scheduledTime) {
|
||||||
|
isLate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if left early (clocked out before scheduled end)
|
||||||
|
if (emp.scheduled_end && timecard.clock_out) {
|
||||||
|
const scheduledEndTime = emp.scheduled_end;
|
||||||
|
const clockOutTimeOnly = timecard.clock_out.includes(' ')
|
||||||
|
? timecard.clock_out.split(' ')[1]?.substring(0, 5)
|
||||||
|
: timecard.clock_out.substring(0, 5);
|
||||||
|
if (clockOutTimeOnly && clockOutTimeOnly < scheduledEndTime) {
|
||||||
|
leftEarly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format times to HH:mm
|
||||||
|
let formattedClockIn = null;
|
||||||
|
if (clockInTime) {
|
||||||
|
const timePart = clockInTime.includes(' ') ? clockInTime.split(' ')[1] : clockInTime;
|
||||||
|
formattedClockIn = timePart.substring(0, 5); // HH:mm
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedClockOut = null;
|
||||||
|
if (clockOutTime) {
|
||||||
|
const timePart = clockOutTime.includes(' ') ? clockOutTime.split(' ')[1] : clockOutTime;
|
||||||
|
formattedClockOut = timePart.substring(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedBreakStart = null;
|
||||||
|
if (breakStartTime) {
|
||||||
|
const timePart = breakStartTime.includes(' ') ? breakStartTime.split(' ')[1] : breakStartTime;
|
||||||
|
formattedBreakStart = timePart.substring(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: emp.id,
|
||||||
|
name: emp.name,
|
||||||
|
email: emp.email,
|
||||||
|
currentStatus,
|
||||||
|
clockInTime: formattedClockIn,
|
||||||
|
clockOutTime: formattedClockOut,
|
||||||
|
breakStartTime: formattedBreakStart,
|
||||||
|
scheduledStart: emp.scheduled_start,
|
||||||
|
scheduledEnd: emp.scheduled_end,
|
||||||
|
isLate,
|
||||||
|
leftEarly,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(statuses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employee status:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clock in/out action (PUBLIC - password only)
|
||||||
|
router.post('/action', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { employeeId, password, action } = req.body;
|
||||||
|
|
||||||
|
if (!employeeId || !password || !action) {
|
||||||
|
return res.status(400).json({ error: 'Employee ID, password, and action are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['clock_in', 'clock_out', 'break_start', 'break_end'].includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', employeeId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Employee not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
const currentDateTime = now.toISOString();
|
||||||
|
|
||||||
|
// Get or create today's timecard
|
||||||
|
let timecard = await db.getAsync(`
|
||||||
|
SELECT * FROM timecards
|
||||||
|
WHERE employee_id = ? AND date = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, employeeId, today);
|
||||||
|
|
||||||
|
if (!timecard) {
|
||||||
|
// Create new timecard
|
||||||
|
const timecardId = uuidv4();
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO timecards (id, employee_id, date, status)
|
||||||
|
VALUES (?, ?, ?, 'pending')
|
||||||
|
`, timecardId, employeeId, today);
|
||||||
|
timecard = { id: timecardId, employee_id: employeeId, date: today };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scheduled shift for today
|
||||||
|
const shift = await db.getAsync(`
|
||||||
|
SELECT start_time, end_time FROM shifts
|
||||||
|
WHERE employee_id = ? AND date = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, employeeId, today);
|
||||||
|
|
||||||
|
// Perform action
|
||||||
|
if (action === 'clock_in') {
|
||||||
|
// Allow clock in if they haven't clocked in today, OR if they've already clocked out
|
||||||
|
if (timecard.clock_in && !timecard.clock_out) {
|
||||||
|
return res.status(400).json({ error: 'Already clocked in. Please clock out first.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If they clocked out earlier today, create a new timecard entry for a new shift
|
||||||
|
if (timecard.clock_out) {
|
||||||
|
const newTimecardId = uuidv4();
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO timecards (id, employee_id, date, status)
|
||||||
|
VALUES (?, ?, ?, 'pending')
|
||||||
|
`, newTimecardId, employeeId, today);
|
||||||
|
timecard = { id: newTimecardId, employee_id: employeeId, date: today, clock_in: null, clock_out: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET clock_in = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, currentDateTime, timecard.id);
|
||||||
|
|
||||||
|
// Check if late
|
||||||
|
if (shift && shift.start_time) {
|
||||||
|
const scheduledTime = new Date(`${today}T${shift.start_time}`);
|
||||||
|
if (now > scheduledTime) {
|
||||||
|
// Mark as late in notes
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET notes = 'Late arrival', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, timecard.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (action === 'clock_out') {
|
||||||
|
if (!timecard.clock_in) {
|
||||||
|
return res.status(400).json({ error: 'Must clock in first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timecard.clock_out) {
|
||||||
|
return res.status(400).json({ error: 'Already clocked out' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hours
|
||||||
|
const clockIn = new Date(timecard.clock_in);
|
||||||
|
const clockOut = now;
|
||||||
|
const diffMs = clockOut - clockIn;
|
||||||
|
const diffHours = diffMs / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
let breakTime = 0;
|
||||||
|
if (timecard.break_start && timecard.break_end) {
|
||||||
|
const breakStart = new Date(timecard.break_start);
|
||||||
|
const breakEnd = new Date(timecard.break_end);
|
||||||
|
breakTime = (breakEnd - breakStart) / (1000 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHours = diffHours - breakTime;
|
||||||
|
const overtimeHours = totalHours > 8 ? totalHours - 8 : 0;
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET clock_out = ?,
|
||||||
|
total_hours = ?,
|
||||||
|
overtime_hours = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, currentDateTime, totalHours, overtimeHours, timecard.id);
|
||||||
|
|
||||||
|
// Check if left early
|
||||||
|
if (shift && shift.end_time) {
|
||||||
|
const scheduledEnd = new Date(`${today}T${shift.end_time}`);
|
||||||
|
if (now < scheduledEnd) {
|
||||||
|
const currentNotes = timecard.notes || '';
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET notes = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, currentNotes ? `${currentNotes}; Left early` : 'Left early', timecard.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (action === 'break_start') {
|
||||||
|
if (!timecard.clock_in) {
|
||||||
|
return res.status(400).json({ error: 'Must clock in first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timecard.break_start && !timecard.break_end) {
|
||||||
|
return res.status(400).json({ error: 'Already on break' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET break_start = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, currentDateTime, timecard.id);
|
||||||
|
|
||||||
|
} else if (action === 'break_end') {
|
||||||
|
if (!timecard.break_start) {
|
||||||
|
return res.status(400).json({ error: 'Not on break' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timecard.break_end) {
|
||||||
|
return res.status(400).json({ error: 'Break already ended' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE timecards
|
||||||
|
SET break_end = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, currentDateTime, timecard.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully ${action.replace('_', ' ')}`,
|
||||||
|
timecardId: timecard.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing clock action:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
143
server/src/routes/users.js
Normal file
143
server/src/routes/users.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||||
|
import db from '../database/db.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
router.get('/', authenticateToken, requireRole('admin', 'hr'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await db.allAsync(`
|
||||||
|
SELECT u.id, u.email, u.name, u.role, u.created_at,
|
||||||
|
e.job_title, e.department, e.status, e.phone
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN employees e ON u.id = e.user_id
|
||||||
|
ORDER BY u.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
router.get('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Users can only view their own profile unless admin/hr
|
||||||
|
if (req.user.id !== id && !['admin', 'hr'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.getAsync(`
|
||||||
|
SELECT u.id, u.email, u.name, u.role, u.created_at,
|
||||||
|
e.job_title, e.department, e.manager_id, e.phone, e.address,
|
||||||
|
e.hire_date, e.status
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN employees e ON u.id = e.user_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
`, id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
router.post('/', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, role, jobTitle, department, phone, address } = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !role) {
|
||||||
|
return res.status(400).json({ error: 'Name, email, and role are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const defaultPassword = 'password123'; // Should be changed on first login
|
||||||
|
const passwordHash = bcrypt.hashSync(defaultPassword, 10);
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO users (id, email, password_hash, name, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, id, email, passwordHash, name, role);
|
||||||
|
|
||||||
|
// Insert employee record if not admin
|
||||||
|
if (role !== 'admin') {
|
||||||
|
await db.runAsync(`
|
||||||
|
INSERT INTO employees (id, user_id, job_title, department, phone, address, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'Active')
|
||||||
|
`, uuidv4(), id, jobTitle, department, phone, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ id, message: 'User created successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message && error.message.includes('UNIQUE constraint')) {
|
||||||
|
return res.status(409).json({ error: 'Email already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
router.put('/:id', authenticateToken, requireRole('admin', 'hr'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, email, role, jobTitle, department, phone, address, status } = req.body;
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE users
|
||||||
|
SET name = ?, email = ?, role = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, name, email, role, id);
|
||||||
|
|
||||||
|
// Update employee record
|
||||||
|
const employee = await db.getAsync('SELECT id FROM employees WHERE user_id = ?', id);
|
||||||
|
if (employee) {
|
||||||
|
await db.runAsync(`
|
||||||
|
UPDATE employees
|
||||||
|
SET job_title = ?, department = ?, phone = ?, address = ?,
|
||||||
|
status = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?
|
||||||
|
`, jobTitle, department, phone, address, status, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'User updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
router.delete('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (id === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync('DELETE FROM users WHERE id = ?', id);
|
||||||
|
|
||||||
|
res.json({ message: 'User deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
41
server/src/server.js
Normal file
41
server/src/server.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import userRoutes from './routes/users.js';
|
||||||
|
import receiptRoutes from './routes/receipts.js';
|
||||||
|
import timecardRoutes from './routes/timecards.js';
|
||||||
|
import timeclockRoutes from './routes/timeclock.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/receipts', receiptRoutes);
|
||||||
|
app.use('/api/timecards', timecardRoutes);
|
||||||
|
app.use('/api/timeclock', timeclockRoutes);
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`Health check: http://localhost:${PORT}/health`);
|
||||||
|
});
|
||||||
|
|
||||||
235
src/App.tsx
Normal file
235
src/App.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { TimeClock } from './pages/TimeClock';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { ClockInOut } from './pages/ClockInOut';
|
||||||
|
import { Timecards } from './pages/Timecards';
|
||||||
|
import { Schedule } from './pages/Schedule';
|
||||||
|
import { TeamTimecards } from './pages/TeamTimecards';
|
||||||
|
import { TeamSchedules } from './pages/TeamSchedules';
|
||||||
|
import { Receipts } from './pages/Receipts';
|
||||||
|
import { DisciplinaryActions } from './pages/DisciplinaryActions';
|
||||||
|
import { Employees } from './pages/Employees';
|
||||||
|
import { PayrollRuns } from './pages/PayrollRuns';
|
||||||
|
import { OCRReview } from './pages/OCRReview';
|
||||||
|
import { SystemSettings } from './pages/SystemSettings';
|
||||||
|
import { AuditLogs } from './pages/AuditLogs';
|
||||||
|
import { UserManagement } from './pages/UserManagement';
|
||||||
|
import { Placeholder } from './pages/Placeholder';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
|
||||||
|
function LoginRoute() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
return isAuthenticated ? <Navigate to="/" replace /> : <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/timeclock" element={<TimeClock />} />
|
||||||
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/clock"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ClockInOut />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/timecards"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Timecards />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schedule"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Schedule />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/documents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="My Documents" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/receipts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Receipts />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/help"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Help" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/team-timecards"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TeamTimecards />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/team-schedules"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TeamSchedules />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/approvals"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Approvals" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/incident-report"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DisciplinaryActions />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/employees"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Employees />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/disciplinary-actions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DisciplinaryActions />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/performance-reviews"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Performance Reviews" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/hr-documents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="HR Documents" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/payroll-runs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PayrollRuns />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/expenditures"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Expenditures" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/invoices"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Invoices" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ocr-review"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<OCRReview />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/financial-reports"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Financial Reports" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/system-settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SystemSettings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/user-management"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<UserManagement />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/automation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Placeholder title="Automation Workflows" />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/audit-logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AuditLogs />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppRoutes />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
44
src/components/ErrorBoundary.tsx
Normal file
44
src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{this.state.error && this.state.error.toString()}
|
||||||
|
<br />
|
||||||
|
{this.state.error?.stack}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
19
src/components/Layout/Layout.tsx
Normal file
19
src/components/Layout/Layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<TopBar />
|
||||||
|
<div className="flex">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
95
src/components/Layout/Sidebar.tsx
Normal file
95
src/components/Layout/Sidebar.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
FolderOpen,
|
||||||
|
Receipt,
|
||||||
|
HelpCircle,
|
||||||
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
|
Star,
|
||||||
|
FileCheck,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
FileSearch,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
UserCog,
|
||||||
|
GitBranch,
|
||||||
|
History,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useUser } from '../../context/UserContext';
|
||||||
|
import { UserRole } from '../../types';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
roles: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home', icon: <Home className="w-5 h-5" />, roles: ['employee', 'manager', 'hr', 'payroll', 'admin'] },
|
||||||
|
{ path: '/clock', label: 'Clock In/Out', icon: <Clock className="w-5 h-5" />, roles: ['employee', 'manager'] },
|
||||||
|
{ path: '/timecards', label: 'My Timecards', icon: <FileText className="w-5 h-5" />, roles: ['employee'] },
|
||||||
|
{ path: '/schedule', label: 'My Schedule', icon: <Calendar className="w-5 h-5" />, roles: ['employee'] },
|
||||||
|
{ path: '/documents', label: 'My Documents', icon: <FolderOpen className="w-5 h-5" />, roles: ['employee'] },
|
||||||
|
{ path: '/receipts', label: 'Submit Receipt', icon: <Receipt className="w-5 h-5" />, roles: ['employee', 'manager'] },
|
||||||
|
{ path: '/help', label: 'Help', icon: <HelpCircle className="w-5 h-5" />, roles: ['employee', 'manager', 'hr', 'payroll', 'admin'] },
|
||||||
|
// Manager items
|
||||||
|
{ path: '/team-timecards', label: 'Team Timecards', icon: <FileText className="w-5 h-5" />, roles: ['manager'] },
|
||||||
|
{ path: '/team-schedules', label: 'Team Schedules', icon: <Calendar className="w-5 h-5" />, roles: ['manager'] },
|
||||||
|
{ path: '/approvals', label: 'Approvals', icon: <FileCheck className="w-5 h-5" />, roles: ['manager'] },
|
||||||
|
{ path: '/incident-report', label: 'Incident Report', icon: <AlertTriangle className="w-5 h-5" />, roles: ['manager'] },
|
||||||
|
// HR items
|
||||||
|
{ path: '/employees', label: 'Employees', icon: <Users className="w-5 h-5" />, roles: ['hr', 'admin'] },
|
||||||
|
{ path: '/disciplinary-actions', label: 'Disciplinary Actions', icon: <AlertTriangle className="w-5 h-5" />, roles: ['hr', 'admin'] },
|
||||||
|
{ path: '/performance-reviews', label: 'Performance Reviews', icon: <Star className="w-5 h-5" />, roles: ['hr', 'admin'] },
|
||||||
|
{ path: '/hr-documents', label: 'HR Documents', icon: <FolderOpen className="w-5 h-5" />, roles: ['hr', 'admin'] },
|
||||||
|
// Payroll items
|
||||||
|
{ path: '/payroll-runs', label: 'Payroll Runs', icon: <DollarSign className="w-5 h-5" />, roles: ['payroll', 'admin'] },
|
||||||
|
{ path: '/expenditures', label: 'Expenditures', icon: <TrendingUp className="w-5 h-5" />, roles: ['payroll', 'admin'] },
|
||||||
|
{ path: '/invoices', label: 'Invoices', icon: <FileText className="w-5 h-5" />, roles: ['payroll', 'admin'] },
|
||||||
|
{ path: '/ocr-review', label: 'OCR Review Queue', icon: <FileSearch className="w-5 h-5" />, roles: ['payroll', 'admin'] },
|
||||||
|
{ path: '/financial-reports', label: 'Financial Reports', icon: <BarChart3 className="w-5 h-5" />, roles: ['payroll', 'admin'] },
|
||||||
|
// Admin items
|
||||||
|
{ path: '/system-settings', label: 'System Settings', icon: <Settings className="w-5 h-5" />, roles: ['admin'] },
|
||||||
|
{ path: '/user-management', label: 'User & Role Management', icon: <UserCog className="w-5 h-5" />, roles: ['admin'] },
|
||||||
|
{ path: '/automation', label: 'Automation Workflows', icon: <GitBranch className="w-5 h-5" />, roles: ['admin'] },
|
||||||
|
{ path: '/audit-logs', label: 'Audit Logs', icon: <History className="w-5 h-5" />, roles: ['admin'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar: React.FC = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const filteredItems = navItems.filter(item => item.roles.includes(user.role));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-gray-50 border-r border-gray-200 h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
|
<nav className="p-4 space-y-1">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
88
src/components/Layout/TopBar.tsx
Normal file
88
src/components/Layout/TopBar.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Search, Bell, User, Settings, LogOut } from 'lucide-react';
|
||||||
|
import { useUser } from '../../context/UserContext';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export const TopBar: React.FC = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 sticky top-0 z-50">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-lg">A</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-gray-900">Axion</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-2 bg-gray-100 rounded-lg px-4 py-2 w-64">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
className="bg-transparent border-none outline-none flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="relative p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
<Bell className="w-5 h-5 text-gray-600" />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-primary-500 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{user.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block text-sm font-medium text-gray-700">{user.name}</span>
|
||||||
|
<User className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
My Profile
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<hr className="my-2" />
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-red-600 text-left"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
15
src/components/ProtectedRoute.tsx
Normal file
15
src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
109
src/context/AuthContext.tsx
Normal file
109
src/context/AuthContext.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { User, UserRole } from '../types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock users database - in production, this would be in a real database
|
||||||
|
const MOCK_USERS = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
password: 'admin123',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: 'admin' as UserRole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
email: 'hr@company.com',
|
||||||
|
password: 'hr123',
|
||||||
|
name: 'HR Manager',
|
||||||
|
role: 'hr' as UserRole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
email: 'payroll@company.com',
|
||||||
|
password: 'payroll123',
|
||||||
|
name: 'Payroll Admin',
|
||||||
|
role: 'payroll' as UserRole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
email: 'manager@company.com',
|
||||||
|
password: 'manager123',
|
||||||
|
name: 'Team Manager',
|
||||||
|
role: 'manager' as UserRole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
email: 'employee@company.com',
|
||||||
|
password: 'employee123',
|
||||||
|
name: 'John Doe',
|
||||||
|
role: 'employee' as UserRole,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for stored session
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
if (storedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const foundUser = MOCK_USERS.find(
|
||||||
|
u => u.email === email && u.password === password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundUser) {
|
||||||
|
const userData: User = {
|
||||||
|
id: foundUser.id,
|
||||||
|
name: foundUser.name,
|
||||||
|
email: foundUser.email,
|
||||||
|
role: foundUser.role,
|
||||||
|
};
|
||||||
|
setUser(userData);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
41
src/context/UserContext.tsx
Normal file
41
src/context/UserContext.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import { User, ClockStatus } from '../types';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface UserContextType {
|
||||||
|
user: User;
|
||||||
|
clockStatus: ClockStatus;
|
||||||
|
setClockStatus: (status: ClockStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useUser = () => {
|
||||||
|
const context = useContext(UserContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUser must be used within UserProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const { user: authUser } = useAuth();
|
||||||
|
const [clockStatus, setClockStatus] = useState<ClockStatus>('clocked_out');
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
user: authUser,
|
||||||
|
clockStatus,
|
||||||
|
setClockStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
109
src/data/mockDatabase.ts
Normal file
109
src/data/mockDatabase.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { User, Timecard, Shift, DisciplinaryAction, Receipt, PayrollRun } from '../types';
|
||||||
|
|
||||||
|
// Mock database - In production, this would be replaced with API calls to a real backend
|
||||||
|
class MockDatabase {
|
||||||
|
private users: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john.doe@company.com',
|
||||||
|
role: 'employee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
email: 'jane.smith@company.com',
|
||||||
|
role: 'employee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
email: 'bob.johnson@company.com',
|
||||||
|
role: 'employee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Alice Brown',
|
||||||
|
email: 'alice.brown@company.com',
|
||||||
|
role: 'manager',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Charlie Wilson',
|
||||||
|
email: 'charlie.wilson@company.com',
|
||||||
|
role: 'employee',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
private timecards: Timecard[] = [];
|
||||||
|
private shifts: Shift[] = [];
|
||||||
|
private disciplinaryActions: DisciplinaryAction[] = [];
|
||||||
|
private receipts: Receipt[] = [];
|
||||||
|
private payrollRuns: PayrollRun[] = [];
|
||||||
|
|
||||||
|
// User CRUD operations
|
||||||
|
getUsers(): User[] {
|
||||||
|
return [...this.users];
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserById(id: string): User | undefined {
|
||||||
|
return this.users.find(u => u.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser(userData: Omit<User, 'id'>): User {
|
||||||
|
const newUser: User = {
|
||||||
|
...userData,
|
||||||
|
id: String(Date.now()),
|
||||||
|
};
|
||||||
|
this.users.push(newUser);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(id: string, updates: Partial<User>): User | null {
|
||||||
|
const index = this.users.findIndex(u => u.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
this.users[index] = { ...this.users[index], ...updates };
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
return this.users[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteUser(id: string): boolean {
|
||||||
|
const index = this.users.findIndex(u => u.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
this.users.splice(index, 1);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local storage persistence
|
||||||
|
private saveToLocalStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('mockUsers', JSON.stringify(this.users));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromLocalStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('mockUsers');
|
||||||
|
if (stored) {
|
||||||
|
this.users = JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load from localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
constructor() {
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockDatabase = new MockDatabase();
|
||||||
|
|
||||||
|
|
||||||
26
src/index.css
Normal file
26
src/index.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
42
src/main.tsx
Normal file
42
src/main.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './context/AuthContext.tsx'
|
||||||
|
import { UserProvider } from './context/UserContext.tsx'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root')
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found. Make sure index.html has <div id="root"></div>')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<App />
|
||||||
|
</UserProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to render app:', error)
|
||||||
|
rootElement.innerHTML = `
|
||||||
|
<div style="padding: 20px; font-family: sans-serif;">
|
||||||
|
<h1>Failed to load application</h1>
|
||||||
|
<p>Check the browser console for details.</p>
|
||||||
|
<pre style="background: #f5f5f5; padding: 10px; margin-top: 10px; overflow: auto;">
|
||||||
|
${error instanceof Error ? error.stack : String(error)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
222
src/pages/AuditLogs.tsx
Normal file
222
src/pages/AuditLogs.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { History, Filter, Search, AlertCircle, Info, XCircle } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const AuditLogs: React.FC = () => {
|
||||||
|
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||||
|
const [filterActor, setFilterActor] = useState<string>('all');
|
||||||
|
const [filterTable, setFilterTable] = useState<string>('all');
|
||||||
|
const [filterSeverity, setFilterSeverity] = useState<string>('all');
|
||||||
|
|
||||||
|
const logs = [
|
||||||
|
{ id: '1', timestamp: new Date(), actor: 'Admin', action: 'UPDATE', table: 'users', object: 'User #123', ip: '192.168.1.1', severity: 'low', before: { role: 'employee' }, after: { role: 'manager' } },
|
||||||
|
{ id: '2', timestamp: new Date(Date.now() - 3600000), actor: 'HR Manager', action: 'CREATE', table: 'disciplinary_actions', object: 'Action #45', ip: '192.168.1.2', severity: 'medium', before: null, after: { employeeId: '123', severity: 'high' } },
|
||||||
|
{ id: '3', timestamp: new Date(Date.now() - 7200000), actor: 'Payroll Admin', action: 'DELETE', table: 'timecards', object: 'Timecard #789', ip: '192.168.1.3', severity: 'high', before: { hours: 8 }, after: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'high':
|
||||||
|
return <XCircle className="w-4 h-4 text-red-600" />;
|
||||||
|
case 'medium':
|
||||||
|
return <AlertCircle className="w-4 h-4 text-yellow-600" />;
|
||||||
|
case 'low':
|
||||||
|
return <Info className="w-4 h-4 text-blue-600" />;
|
||||||
|
default:
|
||||||
|
return <Info className="w-4 h-4 text-gray-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityColor = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Audit Logs</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search logs..."
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={filterActor}
|
||||||
|
onChange={(e) => setFilterActor(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Actors</option>
|
||||||
|
<option value="Admin">Admin</option>
|
||||||
|
<option value="HR Manager">HR Manager</option>
|
||||||
|
<option value="Payroll Admin">Payroll Admin</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterTable}
|
||||||
|
onChange={(e) => setFilterTable(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Tables</option>
|
||||||
|
<option value="users">Users</option>
|
||||||
|
<option value="timecards">Timecards</option>
|
||||||
|
<option value="disciplinary_actions">Disciplinary Actions</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterSeverity}
|
||||||
|
onChange={(e) => setFilterSeverity(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Severities</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
placeholder="Date Range"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Table</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Object</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP Address</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Severity</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{logs.map(log => (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{format(log.timestamp, 'MMM d, yyyy HH:mm:ss')}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{log.actor}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
log.action === 'CREATE' ? 'bg-green-100 text-green-800' :
|
||||||
|
log.action === 'UPDATE' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{log.table}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{log.object}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-600">{log.ip}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium border flex items-center gap-1 w-fit ${getSeverityColor(log.severity)}`}>
|
||||||
|
{getSeverityIcon(log.severity)}
|
||||||
|
{log.severity.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(log.id)}
|
||||||
|
className="text-primary-600 hover:text-primary-900"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedLog && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Audit Log Details</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const log = logs.find(l => l.id === selectedLog);
|
||||||
|
return log ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Timestamp</p>
|
||||||
|
<p className="font-medium">{format(log.timestamp, 'MMM d, yyyy HH:mm:ss')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Actor</p>
|
||||||
|
<p className="font-medium">{log.actor}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Action</p>
|
||||||
|
<p className="font-medium">{log.action}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">IP Address</p>
|
||||||
|
<p className="font-medium">{log.ip}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{log.before && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">Before</p>
|
||||||
|
<pre className="bg-gray-50 p-3 rounded text-sm overflow-x-auto">
|
||||||
|
{JSON.stringify(log.before, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.after && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">After</p>
|
||||||
|
<pre className="bg-gray-50 p-3 rounded text-sm overflow-x-auto">
|
||||||
|
{JSON.stringify(log.after, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
128
src/pages/ClockInOut.tsx
Normal file
128
src/pages/ClockInOut.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { useUser } from '../context/UserContext';
|
||||||
|
import { Clock, Coffee } from 'lucide-react';
|
||||||
|
|
||||||
|
export const ClockInOut: React.FC = () => {
|
||||||
|
const { clockStatus, setClockStatus } = useUser();
|
||||||
|
|
||||||
|
const handleClockIn = () => {
|
||||||
|
setClockStatus('clocked_in');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClockOut = () => {
|
||||||
|
setClockStatus('clocked_out');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartBreak = () => {
|
||||||
|
setClockStatus('on_break');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndBreak = () => {
|
||||||
|
setClockStatus('clocked_in');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Clock In / Clock Out</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||||
|
{/* Main Action Button */}
|
||||||
|
<div className="flex flex-col items-center justify-center mb-8">
|
||||||
|
{clockStatus === 'clocked_out' && (
|
||||||
|
<button
|
||||||
|
onClick={handleClockIn}
|
||||||
|
className="w-64 h-64 bg-primary-600 hover:bg-primary-700 text-white rounded-full flex flex-col items-center justify-center transition-all transform hover:scale-105 shadow-lg"
|
||||||
|
>
|
||||||
|
<Clock className="w-16 h-16 mb-4" />
|
||||||
|
<span className="text-2xl font-bold">CLOCK IN</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{clockStatus === 'clocked_in' && (
|
||||||
|
<button
|
||||||
|
onClick={handleClockOut}
|
||||||
|
className="w-64 h-64 bg-red-600 hover:bg-red-700 text-white rounded-full flex flex-col items-center justify-center transition-all transform hover:scale-105 shadow-lg"
|
||||||
|
>
|
||||||
|
<Clock className="w-16 h-16 mb-4" />
|
||||||
|
<span className="text-2xl font-bold">CLOCK OUT</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{clockStatus === 'on_break' && (
|
||||||
|
<button
|
||||||
|
onClick={handleEndBreak}
|
||||||
|
className="w-64 h-64 bg-yellow-600 hover:bg-yellow-700 text-white rounded-full flex flex-col items-center justify-center transition-all transform hover:scale-105 shadow-lg"
|
||||||
|
>
|
||||||
|
<Coffee className="w-16 h-16 mb-4" />
|
||||||
|
<span className="text-2xl font-bold">END BREAK</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Actions */}
|
||||||
|
{clockStatus === 'clocked_in' && (
|
||||||
|
<div className="flex justify-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={handleStartBreak}
|
||||||
|
className="px-8 py-4 bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Coffee className="w-5 h-5" />
|
||||||
|
Start Break
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shift Timeline */}
|
||||||
|
<div className="border-t border-gray-200 pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Today's Timeline</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">Clock In</p>
|
||||||
|
<p className="text-sm text-gray-600">8:02 AM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{clockStatus === 'on_break' && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">Break Started</p>
|
||||||
|
<p className="text-sm text-gray-600">12:00 PM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules/Notes */}
|
||||||
|
<div className="border-t border-gray-200 pt-6 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Notes</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li>• Lunch break is automatically deducted after 5 hours of work</li>
|
||||||
|
<li>• Please clock in/out at your scheduled location</li>
|
||||||
|
<li>• Contact HR if you need to correct a timecard</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit Info */}
|
||||||
|
<div className="border-t border-gray-200 pt-6 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Last clock in:</span>
|
||||||
|
<span className="font-medium">8:02 AM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Last clock out:</span>
|
||||||
|
<span className="font-medium">Yesterday at 5:14 PM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
408
src/pages/Dashboard.tsx
Normal file
408
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { useUser } from '../context/UserContext';
|
||||||
|
import { Clock, Calendar, FileText, AlertCircle, Users, CheckCircle, XCircle, Coffee, TrendingUp, DollarSign, Settings, Star } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const { user, clockStatus } = useUser();
|
||||||
|
|
||||||
|
const renderEmployeeView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Clock Status */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Current Status</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||||
|
clockStatus === 'clocked_in' ? 'bg-green-100 text-green-800' :
|
||||||
|
clockStatus === 'on_break' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{clockStatus === 'clocked_in' ? 'CLOCKED IN' :
|
||||||
|
clockStatus === 'on_break' ? 'ON BREAK' :
|
||||||
|
'CLOCKED OUT'}
|
||||||
|
</div>
|
||||||
|
<button className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors">
|
||||||
|
{clockStatus === 'clocked_out' ? 'Clock In' :
|
||||||
|
clockStatus === 'clocked_in' ? 'Clock Out' :
|
||||||
|
'End Break'}
|
||||||
|
</button>
|
||||||
|
{clockStatus === 'clocked_in' && (
|
||||||
|
<button className="px-6 py-3 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 transition-colors">
|
||||||
|
Start Break
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Today's Schedule */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-primary-600" />
|
||||||
|
Today's Schedule
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Start Time</span>
|
||||||
|
<span className="font-medium">8:00 AM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">End Time</span>
|
||||||
|
<span className="font-medium">5:00 PM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Break</span>
|
||||||
|
<span className="font-medium">12:00 PM - 1:00 PM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Location</span>
|
||||||
|
<span className="font-medium">Main Office</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Timecard Summary */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-primary-600" />
|
||||||
|
Timecard Summary
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">Hours Today</span>
|
||||||
|
<span className="text-2xl font-bold text-gray-900">4.5</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">Hours This Week</span>
|
||||||
|
<span className="text-2xl font-bold text-gray-900">32.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Shifts */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Upcoming Shifts</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{format(new Date(Date.now() + i * 24 * 60 * 60 * 1000), 'EEEE, MMM d')}</span>
|
||||||
|
<span className="text-gray-600 ml-2">8:00 AM - 5:00 PM</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Main Office</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notices */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-600" />
|
||||||
|
Notices
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-800">Schedule change: Your shift on Friday has been moved to 9:00 AM - 6:00 PM</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">HR Notice: Performance review scheduled for next week</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderManagerView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{renderEmployeeView()}
|
||||||
|
|
||||||
|
{/* Team Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 mb-2">Currently Clocked In</h3>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 mb-2">8</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>John Doe</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>Jane Smith</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 mb-2">Late Today</h3>
|
||||||
|
<div className="text-3xl font-bold text-red-600 mb-2">2</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span>Bob Johnson</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 mb-2">On Break</h3>
|
||||||
|
<div className="text-3xl font-bold text-yellow-600 mb-2">3</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Coffee className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Alice Brown</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval Notifications */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Approval Notifications</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">5 timecards awaiting approval</p>
|
||||||
|
<p className="text-sm text-gray-600">From last week</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700">
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">3 receipts requiring review</p>
|
||||||
|
<p className="text-sm text-gray-600">Submitted yesterday</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700">
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Create Incident Report</h3>
|
||||||
|
<p className="text-sm text-gray-600">Report a workplace incident</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Edit Team Schedule</h3>
|
||||||
|
<p className="text-sm text-gray-600">Manage upcoming shifts</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHRView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Global Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="w-8 h-8 text-primary-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Total Employees</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">247</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Active Disciplinary Cases</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">3</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Star className="w-8 h-8 text-yellow-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Pending Reviews</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="w-8 h-8 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Expiring Documents</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">8</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border-2 border-primary-500 hover:bg-primary-50 transition-colors">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">New Employee</h3>
|
||||||
|
<p className="text-sm text-gray-600">Add a new employee to the system</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border-2 border-red-500 hover:bg-red-50 transition-colors">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Start Disciplinary Action</h3>
|
||||||
|
<p className="text-sm text-gray-600">Initiate a disciplinary process</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border-2 border-yellow-500 hover:bg-yellow-50 transition-colors">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Schedule Review</h3>
|
||||||
|
<p className="text-sm text-gray-600">Plan performance reviews</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alerts</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-800">3 performance reviews overdue</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-800">8 documents expiring in the next 30 days</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPayrollView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Payroll Countdown */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-lg shadow-sm p-6 text-white">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Next Payroll Run</h2>
|
||||||
|
<p className="text-3xl font-bold mb-4">3 days</p>
|
||||||
|
<p className="text-primary-100">Scheduled for Friday, December 15, 2023</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
|
||||||
|
<DollarSign className="w-8 h-8 text-primary-600 mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Start Payroll Preview</h3>
|
||||||
|
<p className="text-sm text-gray-600">Generate preview for next payroll</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
|
||||||
|
<FileText className="w-8 h-8 text-primary-600 mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Review Receipts/Invoices</h3>
|
||||||
|
<p className="text-sm text-gray-600">Process expense submissions</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
|
||||||
|
<TrendingUp className="w-8 h-8 text-primary-600 mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Expense Summaries</h3>
|
||||||
|
<p className="text-sm text-gray-600">View expense reports</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alerts</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-800">5 OCR errors require attention</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-800">12 unapproved timecards</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAdminView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* System Health */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-600">API Status</span>
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">99.9%</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Uptime</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-600">n8n Status</span>
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">Online</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">All workflows active</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-600">Backup Status</span>
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">Current</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Last: 2 hours ago</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Audit Logs */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Audit Logs</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">User role changed</p>
|
||||||
|
<p className="text-xs text-gray-500">Admin • 2 minutes ago</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">192.168.1.1</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Link to="/system-settings" className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left block">
|
||||||
|
<Settings className="w-8 h-8 text-primary-600 mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">System Settings</h3>
|
||||||
|
<p className="text-sm text-gray-600">Configure system parameters</p>
|
||||||
|
</Link>
|
||||||
|
<Link to="/user-management" className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left block">
|
||||||
|
<Users className="w-8 h-8 text-primary-600 mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">User Provisioning</h3>
|
||||||
|
<p className="text-sm text-gray-600">Manage user accounts</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (user.role) {
|
||||||
|
case 'employee':
|
||||||
|
return renderEmployeeView();
|
||||||
|
case 'manager':
|
||||||
|
return renderManagerView();
|
||||||
|
case 'hr':
|
||||||
|
return renderHRView();
|
||||||
|
case 'payroll':
|
||||||
|
return renderPayrollView();
|
||||||
|
case 'admin':
|
||||||
|
return renderAdminView();
|
||||||
|
default:
|
||||||
|
return renderEmployeeView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
211
src/pages/DisciplinaryActions.tsx
Normal file
211
src/pages/DisciplinaryActions.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { useUser } from '../context/UserContext';
|
||||||
|
import { AlertTriangle, FileText, Send, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const DisciplinaryActions: React.FC = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
employeeId: '',
|
||||||
|
date: '',
|
||||||
|
details: '',
|
||||||
|
severity: 'low' as 'low' | 'medium' | 'high' | 'critical',
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ id: '1', employeeName: 'John Doe', date: '2023-12-10', severity: 'medium', status: 'pending_approval', details: 'Late arrival on multiple occasions' },
|
||||||
|
{ id: '2', employeeName: 'Jane Smith', date: '2023-12-08', severity: 'high', status: 'finalized', details: 'Policy violation' },
|
||||||
|
{ id: '3', employeeName: 'Bob Johnson', date: '2023-12-05', severity: 'low', status: 'draft', details: 'Performance issue' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const employees = [
|
||||||
|
{ id: '1', name: 'John Doe' },
|
||||||
|
{ id: '2', name: 'Jane Smith' },
|
||||||
|
{ id: '3', name: 'Bob Johnson' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSeverityColor = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'finalized':
|
||||||
|
return <span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Finalized</span>;
|
||||||
|
case 'pending_approval':
|
||||||
|
return <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium flex items-center gap-1"><Clock className="w-3 h-3" /> Pending Approval</span>;
|
||||||
|
case 'draft':
|
||||||
|
return <span className="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs font-medium">Draft</span>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user.role === 'manager') {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Incident Report</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
{showForm ? 'Cancel' : 'New Incident Report'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Report Incident</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Employee</label>
|
||||||
|
<select
|
||||||
|
value={formData.employeeId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="">Select employee</option>
|
||||||
|
{employees.map(emp => (
|
||||||
|
<option key={emp.id} value={emp.id}>{emp.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date of Incident</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Severity</label>
|
||||||
|
<select
|
||||||
|
value={formData.severity}
|
||||||
|
onChange={(e) => setFormData({ ...formData, severity: e.target.value as any })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Details</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.details}
|
||||||
|
onChange={(e) => setFormData({ ...formData, details: e.target.value })}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
placeholder="Describe the incident in detail..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Attachments (Optional)</label>
|
||||||
|
<input type="file" className="w-full px-3 py-2 border border-gray-300 rounded-lg" multiple />
|
||||||
|
</div>
|
||||||
|
<button className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
Submit Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HR View
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Disciplinary Actions</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Employee</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Incident Date</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Severity</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{actions.map(action => (
|
||||||
|
<tr key={action.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{action.employeeName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{format(new Date(action.date), 'MMM d, yyyy')}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium border ${getSeverityColor(action.severity)}`}>
|
||||||
|
{action.severity.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(action.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button className="text-primary-600 hover:text-primary-900 mr-4">Review</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail View Modal would go here */}
|
||||||
|
<div className="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Incident Details</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Employee</p>
|
||||||
|
<p className="font-medium">John Doe</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Details</p>
|
||||||
|
<p className="text-gray-900">Late arrival on multiple occasions without proper notification...</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 flex items-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Send to Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
425
src/pages/Employees.tsx
Normal file
425
src/pages/Employees.tsx
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Users, Edit, Eye, MoreVertical, Search, Filter, Trash2, Save, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
jobTitle: string;
|
||||||
|
department: string;
|
||||||
|
status: string;
|
||||||
|
manager: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
hireDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Employees: React.FC = () => {
|
||||||
|
const [selectedEmployee, setSelectedEmployee] = useState<string | null>(null);
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false);
|
||||||
|
const [showMenu, setShowMenu] = useState<string | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([
|
||||||
|
{ id: '1', name: 'John Doe', jobTitle: 'Cashier', department: 'Retail', status: 'Active', manager: 'Jane Manager', email: 'john.doe@company.com', phone: '(555) 123-4567', address: '123 Main St, City, ST 12345', hireDate: 'January 15, 2022' },
|
||||||
|
{ id: '2', name: 'Jane Smith', jobTitle: 'Stock Associate', department: 'Warehouse', status: 'Active', manager: 'Bob Manager', email: 'jane.smith@company.com', phone: '(555) 234-5678', address: '456 Oak Ave, City, ST 12345', hireDate: 'March 20, 2021' },
|
||||||
|
{ id: '3', name: 'Bob Johnson', jobTitle: 'Manager', department: 'Retail', status: 'Active', manager: 'Alice Director', email: 'bob.johnson@company.com', phone: '(555) 345-6789', address: '789 Pine Rd, City, ST 12345', hireDate: 'June 10, 2020' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showMenu]);
|
||||||
|
|
||||||
|
const handleEdit = (emp: Employee) => {
|
||||||
|
setEditingEmployee({ ...emp });
|
||||||
|
setShowEditForm(true);
|
||||||
|
setShowMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editingEmployee) {
|
||||||
|
setEmployees(employees.map(emp =>
|
||||||
|
emp.id === editingEmployee.id ? editingEmployee : emp
|
||||||
|
));
|
||||||
|
setShowEditForm(false);
|
||||||
|
setEditingEmployee(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this employee?')) {
|
||||||
|
setEmployees(employees.filter(emp => emp.id !== id));
|
||||||
|
setShowMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedEmployee) {
|
||||||
|
const emp = employees.find(e => e.id === selectedEmployee);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEmployee(null)}
|
||||||
|
className="mb-4 text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
← Back to List
|
||||||
|
</button>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Employee Details</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{/* Personal Info */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Personal Information</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Full Name</p>
|
||||||
|
<p className="font-medium">{emp?.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Email</p>
|
||||||
|
<p className="font-medium">{emp?.email || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Phone</p>
|
||||||
|
<p className="font-medium">{emp?.phone || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Address</p>
|
||||||
|
<p className="font-medium">{emp?.address || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employment Data */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Employment Information</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Job Title</p>
|
||||||
|
<p className="font-medium">{emp?.jobTitle}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Department</p>
|
||||||
|
<p className="font-medium">{emp?.department}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Manager</p>
|
||||||
|
<p className="font-medium">{emp?.manager}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Hire Date</p>
|
||||||
|
<p className="font-medium">{emp?.hireDate || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Documents</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">Employment Contract</span>
|
||||||
|
<button className="text-primary-600 hover:text-primary-700 text-sm">View</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">ID Verification</span>
|
||||||
|
<button className="text-primary-600 hover:text-primary-700 text-sm">View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disciplinary History */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Disciplinary History</h2>
|
||||||
|
<p className="text-sm text-gray-600">No disciplinary actions on record</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Summary</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Hours This Week</p>
|
||||||
|
<p className="text-2xl font-bold">32.0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Timecards Pending</p>
|
||||||
|
<p className="text-2xl font-bold">2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Employees</h1>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
New Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 flex items-center gap-2 bg-gray-100 rounded-lg px-4 py-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search employees..."
|
||||||
|
className="bg-transparent border-none outline-none flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employees Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Title</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Department</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Manager</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{employees.map(emp => (
|
||||||
|
<tr key={emp.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{emp.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{emp.jobTitle}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{emp.department}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium">
|
||||||
|
{emp.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{emp.manager}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-2 relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEmployee(emp.id)}
|
||||||
|
className="text-primary-600 hover:text-primary-900"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(emp)}
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(showMenu === emp.id ? null : emp.id)}
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{showMenu === emp.id && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleEdit(emp);
|
||||||
|
setShowMenu(null);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700 text-left"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
Edit Employee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(emp.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-red-600 text-left"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form Modal */}
|
||||||
|
{showEditForm && editingEmployee && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Edit Employee</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditForm(false);
|
||||||
|
setEditingEmployee(null);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.name}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={editingEmployee.email || ''}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, email: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Job Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.jobTitle}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, jobTitle: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.department}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, department: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={editingEmployee.phone || ''}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Manager</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.manager}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, manager: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={editingEmployee.status}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, status: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Inactive">Inactive</option>
|
||||||
|
<option value="On Leave">On Leave</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Hire Date</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.hireDate || ''}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, hireDate: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingEmployee.address || ''}
|
||||||
|
onChange={(e) => setEditingEmployee({ ...editingEmployee, address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditForm(false);
|
||||||
|
setEditingEmployee(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
src/pages/Login.tsx
Normal file
130
src/pages/Login.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { LogIn, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Login: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const success = await login(email, password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setError('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary-600 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-white font-bold text-2xl">A</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Axion</h1>
|
||||||
|
<p className="text-gray-600">HR/Payroll System</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
<span className="text-sm text-red-800">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
Sign In
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600 text-center mb-4">Demo Accounts:</p>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">Admin:</span>
|
||||||
|
<span className="text-gray-600">admin@company.com / admin123</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">HR:</span>
|
||||||
|
<span className="text-gray-600">hr@company.com / hr123</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">Payroll:</span>
|
||||||
|
<span className="text-gray-600">payroll@company.com / payroll123</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">Manager:</span>
|
||||||
|
<span className="text-gray-600">manager@company.com / manager123</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">Employee:</span>
|
||||||
|
<span className="text-gray-600">employee@company.com / employee123</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
157
src/pages/OCRReview.tsx
Normal file
157
src/pages/OCRReview.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { CheckCircle, XCircle, Edit, Tag, DollarSign, Calendar, Building2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const OCRReview: React.FC = () => {
|
||||||
|
const [selectedReceipt, setSelectedReceipt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const receipts = [
|
||||||
|
{ id: '1', imageUrl: '', vendor: 'Office Depot', amount: 45.99, date: '2023-12-10', tax: 3.68, category: 'Office Supplies', confidence: 0.92 },
|
||||||
|
{ id: '2', imageUrl: '', vendor: 'Starbucks', amount: 12.50, date: '2023-12-11', tax: 1.00, category: 'Meals', confidence: 0.88 },
|
||||||
|
{ id: '3', imageUrl: '', vendor: 'Uber', amount: 28.75, date: '2023-12-11', tax: 0, category: 'Transportation', confidence: 0.65 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 0.8) return 'text-green-600';
|
||||||
|
if (confidence >= 0.6) return 'text-yellow-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedReceipt) {
|
||||||
|
const receipt = receipts.find(r => r.id === selectedReceipt);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedReceipt(null)}
|
||||||
|
className="mb-4 text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
← Back to Queue
|
||||||
|
</button>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">OCR Review</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Receipt Image</h2>
|
||||||
|
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||||||
|
<p className="text-gray-500">Receipt thumbnail would appear here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Parsed Fields</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Vendor
|
||||||
|
</label>
|
||||||
|
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue={receipt?.vendor} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
Amount
|
||||||
|
</label>
|
||||||
|
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue={receipt?.amount} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input type="date" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue={receipt?.date} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue={receipt?.category}>
|
||||||
|
<option>Office Supplies</option>
|
||||||
|
<option>Meals</option>
|
||||||
|
<option>Transportation</option>
|
||||||
|
<option>Equipment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">OCR Confidence</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${getConfidenceColor(receipt?.confidence || 0)}`}
|
||||||
|
style={{ width: `${(receipt?.confidence || 0) * 100}%`, backgroundColor: receipt && receipt.confidence >= 0.8 ? '#10b981' : receipt && receipt.confidence >= 0.6 ? '#f59e0b' : '#ef4444' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-medium ${getConfidenceColor(receipt?.confidence || 0)}`}>
|
||||||
|
{(receipt?.confidence || 0) * 100}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-6">
|
||||||
|
<button className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 flex items-center justify-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 flex items-center justify-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">OCR Review Queue</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{receipts.map(receipt => (
|
||||||
|
<div
|
||||||
|
key={receipt.id}
|
||||||
|
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:border-primary-500 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setSelectedReceipt(receipt.id)}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="bg-gray-100 rounded-lg h-32 flex items-center justify-center mb-4">
|
||||||
|
<p className="text-gray-500 text-sm">Receipt Image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{receipt.vendor}</span>
|
||||||
|
<span className={`text-xs font-medium ${getConfidenceColor(receipt.confidence)}`}>
|
||||||
|
{(receipt.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>Amount: ${receipt.amount.toFixed(2)}</p>
|
||||||
|
<p>Date: {format(new Date(receipt.date), 'MMM d, yyyy')}</p>
|
||||||
|
<p>Category: {receipt.category}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button className="flex-1 px-3 py-2 bg-green-600 text-white rounded text-sm font-medium hover:bg-green-700">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-3 py-2 bg-gray-200 text-gray-700 rounded text-sm font-medium hover:bg-gray-300 flex items-center justify-center gap-1">
|
||||||
|
<Edit className="w-3 h-3" />
|
||||||
|
Fix
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
190
src/pages/PayrollRuns.tsx
Normal file
190
src/pages/PayrollRuns.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { DollarSign, FileText, Download, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const PayrollRuns: React.FC = () => {
|
||||||
|
const [selectedRun, setSelectedRun] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const payrollRuns = [
|
||||||
|
{ id: '1', payPeriod: '2023-12-01 to 2023-12-15', status: 'completed', totalHours: 1240, totalGross: 24800, totalNet: 19840, errors: 0 },
|
||||||
|
{ id: '2', payPeriod: '2023-11-16 to 2023-11-30', status: 'pending', totalHours: 1200, totalGross: 24000, totalNet: 19200, errors: 2 },
|
||||||
|
{ id: '3', payPeriod: '2023-11-01 to 2023-11-15', status: 'preview', totalHours: 1180, totalGross: 23600, totalNet: 18880, errors: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Completed</span>;
|
||||||
|
case 'pending':
|
||||||
|
return <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium flex items-center gap-1"><Clock className="w-3 h-3" /> Pending</span>;
|
||||||
|
case 'preview':
|
||||||
|
return <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium flex items-center gap-1"><FileText className="w-3 h-3" /> Preview</span>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedRun) {
|
||||||
|
const run = payrollRuns.find(r => r.id === selectedRun);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRun(null)}
|
||||||
|
className="mb-4 text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
← Back to List
|
||||||
|
</button>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Payroll Run Details</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Pay Period: {run?.payPeriod}</h2>
|
||||||
|
{getStatusBadge(run?.status || '')}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Export PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Total Hours</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{run?.totalHours.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Total Gross</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${run?.totalGross.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Total Net</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${run?.totalNet.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Errors</p>
|
||||||
|
<p className={`text-2xl font-bold ${run && run.errors > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{run?.errors}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employee Line Items */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Employee</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Hours</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rate</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Gross</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Deductions</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Taxes</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Net</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">Employee {i}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">40</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">$20.00</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">$800.00</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">$50.00</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">$200.00</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">$550.00</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{run?.status === 'preview' && (
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<button className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
Approve & Finalize
|
||||||
|
</button>
|
||||||
|
<button className="px-6 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300">
|
||||||
|
Make Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Payroll Runs</h1>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
Start Payroll Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Pay Period</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total Hours</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total Gross</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total Net</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{payrollRuns.map(run => (
|
||||||
|
<tr key={run.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{run.payPeriod}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(run.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{run.totalHours.toLocaleString()}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">${run.totalGross.toLocaleString()}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">${run.totalNet.toLocaleString()}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className={`text-sm font-medium ${run.errors > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{run.errors}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRun(run.id)}
|
||||||
|
className="text-primary-600 hover:text-primary-900"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
18
src/pages/Placeholder.tsx
Normal file
18
src/pages/Placeholder.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
|
||||||
|
export const Placeholder: React.FC<{ title: string }> = ({ title }) => {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">{title}</h1>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
|
<p className="text-gray-600">This page is under construction.</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Content will be implemented here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
184
src/pages/Receipts.tsx
Normal file
184
src/pages/Receipts.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Upload, FileText, CheckCircle, AlertCircle, Clock, DollarSign, Calendar, Tag } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const Receipts: React.FC = () => {
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const receipts = [
|
||||||
|
{ id: '1', vendor: 'Office Depot', amount: 45.99, date: '2023-12-10', tax: 3.68, category: 'Office Supplies', status: 'approved', imageUrl: '' },
|
||||||
|
{ id: '2', vendor: 'Starbucks', amount: 12.50, date: '2023-12-11', tax: 1.00, category: 'Meals', status: 'pending', imageUrl: '' },
|
||||||
|
{ id: '3', vendor: 'Uber', amount: 28.75, date: '2023-12-11', tax: 0, category: 'Transportation', status: 'needs_review', ocrConfidence: 0.65, imageUrl: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
handleFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
// Simulate OCR processing
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploading(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Approved</span>;
|
||||||
|
case 'pending':
|
||||||
|
return <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium flex items-center gap-1"><Clock className="w-3 h-3" /> Pending</span>;
|
||||||
|
case 'needs_review':
|
||||||
|
return <span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs font-medium flex items-center gap-1"><AlertCircle className="w-3 h-3" /> Needs Review</span>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Submit Receipt / Invoice</h1>
|
||||||
|
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 mb-6">
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-300 hover:border-primary-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Drag and drop your receipt or invoice
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">or</p>
|
||||||
|
<label className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 cursor-pointer">
|
||||||
|
Browse Files
|
||||||
|
<input type="file" className="hidden" accept="image/*,.pdf" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mt-4">Supports JPG, PNG, PDF up to 10MB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
||||||
|
<span className="text-sm text-blue-800">Processing via OCR...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field Preview (shown after upload) */}
|
||||||
|
{uploading && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Extracted Information</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor</label>
|
||||||
|
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Auto-filled from OCR" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Amount</label>
|
||||||
|
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
||||||
|
<input type="date" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tax</label>
|
||||||
|
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||||
|
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
<option>Office Supplies</option>
|
||||||
|
<option>Meals</option>
|
||||||
|
<option>Transportation</option>
|
||||||
|
<option>Equipment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-4 px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
Submit Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Receipts List */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Submissions</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{receipts.map(receipt => (
|
||||||
|
<div key={receipt.id} className="p-4 border border-gray-200 rounded-lg hover:border-primary-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileText className="w-5 h-5 text-primary-600" />
|
||||||
|
<span className="font-semibold text-gray-900">{receipt.vendor}</span>
|
||||||
|
{getStatusBadge(receipt.status)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">Amount:</span>
|
||||||
|
<span className="font-medium">${receipt.amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{format(new Date(receipt.date), 'MMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{receipt.category}</span>
|
||||||
|
</div>
|
||||||
|
{receipt.ocrConfidence && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-600">OCR Confidence:</span>
|
||||||
|
<span className={`font-medium ${receipt.ocrConfidence < 0.7 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{(receipt.ocrConfidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
100
src/pages/Schedule.tsx
Normal file
100
src/pages/Schedule.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Calendar, Clock, MapPin, FileText } from 'lucide-react';
|
||||||
|
import { format, addDays, startOfWeek } from 'date-fns';
|
||||||
|
|
||||||
|
export const Schedule: React.FC = () => {
|
||||||
|
const [view, setView] = useState<'weekly' | 'biweekly'>('weekly');
|
||||||
|
const [currentWeek] = useState(startOfWeek(new Date()));
|
||||||
|
|
||||||
|
const shifts = [
|
||||||
|
{ date: addDays(currentWeek, 0), startTime: '8:00 AM', endTime: '5:00 PM', role: 'Cashier', location: 'Main Store', notes: 'Opening shift' },
|
||||||
|
{ date: addDays(currentWeek, 1), startTime: '8:00 AM', endTime: '5:00 PM', role: 'Cashier', location: 'Main Store' },
|
||||||
|
{ date: addDays(currentWeek, 2), startTime: '9:00 AM', endTime: '6:00 PM', role: 'Stock Associate', location: 'Warehouse' },
|
||||||
|
{ date: addDays(currentWeek, 3), startTime: '8:00 AM', endTime: '5:00 PM', role: 'Cashier', location: 'Main Store' },
|
||||||
|
{ date: addDays(currentWeek, 4), startTime: '8:00 AM', endTime: '5:00 PM', role: 'Cashier', location: 'Main Store' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">My Schedule</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('weekly')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
view === 'weekly' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('biweekly')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
view === 'biweekly' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Biweekly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Shift Countdown */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-lg shadow-sm p-6 text-white mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Next Shift</h2>
|
||||||
|
<p className="text-2xl font-bold mb-1">Tomorrow at 8:00 AM</p>
|
||||||
|
<p className="text-primary-100">Main Store - Cashier</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar View */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Schedule Calendar</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{shifts.map((shift, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 border border-gray-200 rounded-lg hover:border-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="w-4 h-4 text-primary-600" />
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{format(shift.date, 'EEEE, MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{shift.startTime} - {shift.endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">{shift.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">{shift.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shift.notes && (
|
||||||
|
<p className="text-sm text-gray-600 mt-2 italic">{shift.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Request Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
192
src/pages/SystemSettings.tsx
Normal file
192
src/pages/SystemSettings.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Settings, Key, Database, Palette, Save } from 'lucide-react';
|
||||||
|
|
||||||
|
export const SystemSettings: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'users' | 'api' | 'automation' | 'backup' | 'branding'>('users');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">System Settings</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ id: 'users', label: 'User & Roles', icon: <Settings className="w-4 h-4" /> },
|
||||||
|
{ id: 'api', label: 'API Keys', icon: <Key className="w-4 h-4" /> },
|
||||||
|
{ id: 'automation', label: 'n8n Connections', icon: <Settings className="w-4 h-4" /> },
|
||||||
|
{ id: 'backup', label: 'Backup/Restore', icon: <Database className="w-4 h-4" /> },
|
||||||
|
{ id: 'branding', label: 'Branding', icon: <Palette className="w-4 h-4" /> },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`w-full flex items-center gap-2 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">User & Role Management</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 mb-2">Permissions Matrix</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">Role</th>
|
||||||
|
<th className="px-4 py-2 text-center">View</th>
|
||||||
|
<th className="px-4 py-2 text-center">Edit</th>
|
||||||
|
<th className="px-4 py-2 text-center">Delete</th>
|
||||||
|
<th className="px-4 py-2 text-center">Admin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{['Employee', 'Manager', 'HR', 'Payroll', 'Admin'].map(role => (
|
||||||
|
<tr key={role} className="border-t">
|
||||||
|
<td className="px-4 py-2 font-medium">{role}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input type="checkbox" defaultChecked className="rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input type="checkbox" defaultChecked={['Manager', 'HR', 'Admin'].includes(role)} className="rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input type="checkbox" defaultChecked={role === 'Admin'} className="rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input type="checkbox" defaultChecked={role === 'Admin'} className="rounded" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'api' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">API Keys</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">OCR API Key</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="password" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" defaultValue="••••••••••••" />
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">Show</button>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Regenerate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">LLM API Key</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="password" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" defaultValue="••••••••••••" />
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">Show</button>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Regenerate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'automation' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">n8n Connections</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">Email Workflow</span>
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Active</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">n8n workflow URL: https://n8n.company.com/webhook/email</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">SMS Workflow</span>
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Active</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">n8n workflow URL: https://n8n.company.com/webhook/sms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'backup' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Backup & Restore</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">Last Backup</span>
|
||||||
|
<span className="text-sm text-gray-600">2 hours ago</span>
|
||||||
|
</div>
|
||||||
|
<button className="mt-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
|
||||||
|
Create Backup Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Restore from Backup</label>
|
||||||
|
<input type="file" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
||||||
|
<button className="mt-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'branding' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Branding</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Company Logo</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-32 h-32 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-gray-400">Logo</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="file" className="mb-2" />
|
||||||
|
<p className="text-xs text-gray-500">Recommended: 200x200px PNG</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Primary Color</label>
|
||||||
|
<input type="color" className="w-32 h-10 rounded" defaultValue="#0ea5e9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
194
src/pages/TeamSchedules.tsx
Normal file
194
src/pages/TeamSchedules.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Calendar, Plus, Copy, Zap, Send, Edit, Trash2 } from 'lucide-react';
|
||||||
|
import { format, startOfWeek, addDays } from 'date-fns';
|
||||||
|
|
||||||
|
export const TeamSchedules: React.FC = () => {
|
||||||
|
const [currentWeek] = useState(startOfWeek(new Date()));
|
||||||
|
const [selectedShift, setSelectedShift] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const employees = ['John Doe', 'Jane Smith', 'Bob Johnson', 'Alice Brown', 'Charlie Wilson'];
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i));
|
||||||
|
|
||||||
|
const shifts: Record<string, Record<string, { start: string; end: string; role: string }>> = {
|
||||||
|
'John Doe': {
|
||||||
|
'0': { start: '8:00 AM', end: '5:00 PM', role: 'Cashier' },
|
||||||
|
'1': { start: '8:00 AM', end: '5:00 PM', role: 'Cashier' },
|
||||||
|
'3': { start: '9:00 AM', end: '6:00 PM', role: 'Manager' },
|
||||||
|
},
|
||||||
|
'Jane Smith': {
|
||||||
|
'1': { start: '9:00 AM', end: '6:00 PM', role: 'Stock' },
|
||||||
|
'2': { start: '8:00 AM', end: '5:00 PM', role: 'Cashier' },
|
||||||
|
'4': { start: '8:00 AM', end: '5:00 PM', role: 'Cashier' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Team Schedules</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Week Selector
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Publish Week
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Controls */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Shift
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Copy Last Week
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Auto-Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Main Grid */}
|
||||||
|
<div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 z-10 border-r border-gray-200">
|
||||||
|
Employee
|
||||||
|
</th>
|
||||||
|
{weekDays.map((day, idx) => (
|
||||||
|
<th key={idx} className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-[120px]">
|
||||||
|
<div>{format(day, 'EEE')}</div>
|
||||||
|
<div className="text-xs text-gray-400">{format(day, 'MMM d')}</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{employees.map((emp, empIdx) => (
|
||||||
|
<tr key={empIdx}>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900 sticky left-0 bg-white z-10 border-r border-gray-200">
|
||||||
|
{emp}
|
||||||
|
</td>
|
||||||
|
{weekDays.map((day, dayIdx) => {
|
||||||
|
const shift = shifts[emp]?.[dayIdx.toString()];
|
||||||
|
return (
|
||||||
|
<td key={dayIdx} className="px-2 py-2">
|
||||||
|
{shift ? (
|
||||||
|
<div
|
||||||
|
className="p-2 bg-primary-100 border border-primary-300 rounded cursor-pointer hover:bg-primary-200 transition-colors"
|
||||||
|
onClick={() => setSelectedShift(`${emp}-${dayIdx}`)}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-primary-900">{shift.start} - {shift.end}</div>
|
||||||
|
<div className="text-xs text-primary-700">{shift.role}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 border-2 border-dashed border-gray-200 rounded cursor-pointer hover:border-primary-300 transition-colors text-center text-xs text-gray-400">
|
||||||
|
Click to add
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Unassigned Shifts</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium">Monday 8:00 AM</p>
|
||||||
|
<p className="text-xs text-gray-600">Cashier needed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Coverage</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{weekDays.map((day, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-xs text-gray-600">{format(day, 'EEE')}</span>
|
||||||
|
<span className="text-xs font-medium text-green-600">✓ Covered</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Edit Drawer */}
|
||||||
|
{selectedShift && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Edit Shift</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
|
||||||
|
<input type="time" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue="08:00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">End Time</label>
|
||||||
|
<input type="time" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue="17:00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||||
|
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
<option>Cashier</option>
|
||||||
|
<option>Stock Associate</option>
|
||||||
|
<option>Manager</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
|
||||||
|
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-lg" defaultValue="Main Store" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-lg" rows={3}></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" className="rounded" />
|
||||||
|
<span className="text-sm text-gray-700">Repeat weekly</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedShift(null)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
189
src/pages/TeamTimecards.tsx
Normal file
189
src/pages/TeamTimecards.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { CheckCircle, XCircle, AlertCircle, Clock, Filter } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const TeamTimecards: React.FC = () => {
|
||||||
|
const [selectedEmployees, setSelectedEmployees] = useState<string[]>([]);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'flagged'>('all');
|
||||||
|
|
||||||
|
const employees = [
|
||||||
|
{ id: '1', name: 'John Doe', status: 'clocked_in', clockIn: '8:02 AM' },
|
||||||
|
{ id: '2', name: 'Jane Smith', status: 'clocked_in', clockIn: '8:00 AM' },
|
||||||
|
{ id: '3', name: 'Bob Johnson', status: 'on_break', clockIn: '8:15 AM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const timecards = [
|
||||||
|
{ id: '1', employeeId: '1', employeeName: 'John Doe', date: '2023-12-11', clockIn: '8:02 AM', clockOut: '5:14 PM', hours: 8, status: 'approved' },
|
||||||
|
{ id: '2', employeeId: '2', employeeName: 'Jane Smith', date: '2023-12-11', clockIn: '8:00 AM', clockOut: '5:00 PM', hours: 8, status: 'pending' },
|
||||||
|
{ id: '3', employeeId: '3', employeeName: 'Bob Johnson', date: '2023-12-11', clockIn: '8:15 AM', clockOut: null, hours: 4, status: 'flagged' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toggleEmployee = (id: string) => {
|
||||||
|
setSelectedEmployees(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Approved</span>;
|
||||||
|
case 'pending':
|
||||||
|
return <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium flex items-center gap-1"><AlertCircle className="w-3 h-3" /> Pending</span>;
|
||||||
|
case 'flagged':
|
||||||
|
return <span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs font-medium flex items-center gap-1"><XCircle className="w-3 h-3" /> Flagged</span>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTimecards = filterStatus === 'all'
|
||||||
|
? timecards
|
||||||
|
: timecards.filter(tc => tc.status === filterStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Team Timecards</h1>
|
||||||
|
|
||||||
|
{/* Live Status */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Currently Clocked In</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{employees.map(emp => (
|
||||||
|
<div key={emp.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
|
emp.status === 'clocked_in' ? 'bg-green-500' :
|
||||||
|
emp.status === 'on_break' ? 'bg-yellow-500' :
|
||||||
|
'bg-gray-400'
|
||||||
|
}`}></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">{emp.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">Clocked in: {emp.clockIn}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Filter className="w-5 h-5 text-gray-400" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['all', 'pending', 'approved', 'flagged'] as const).map(status => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => setFilterStatus(status)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filterStatus === status
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timecards Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<input type="checkbox" className="rounded" />
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Employee</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Clock In</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Clock Out</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hours</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{filteredTimecards.map(tc => (
|
||||||
|
<tr key={tc.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedEmployees.includes(tc.id)}
|
||||||
|
onChange={() => toggleEmployee(tc.id)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{tc.employeeName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{format(new Date(tc.date), 'MMM d, yyyy')}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{tc.clockIn}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{tc.clockOut || 'Missing'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{tc.hours}h</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(tc.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button className="text-primary-600 hover:text-primary-900 mr-4">Approve</button>
|
||||||
|
<button className="text-red-600 hover:text-red-900">Deny</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Actions */}
|
||||||
|
{selectedEmployees.length > 0 && (
|
||||||
|
<div className="mt-6 bg-primary-50 border border-primary-200 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-primary-900">
|
||||||
|
{selectedEmployees.length} timecard(s) selected
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
|
||||||
|
Approve Selected
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700">
|
||||||
|
Deny Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
<span className="font-medium text-red-900">Missing Clock-Out</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-800">Bob Johnson - December 11, 2023</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Clock className="w-5 h-5 text-yellow-600" />
|
||||||
|
<span className="font-medium text-yellow-900">Excessive Overtime</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-yellow-800">John Doe - 12 hours on December 10, 2023</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
406
src/pages/TimeClock.tsx
Normal file
406
src/pages/TimeClock.tsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Clock, Lock } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface EmployeeStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
currentStatus: 'clocked_in' | 'clocked_out' | 'on_break';
|
||||||
|
clockInTime?: string;
|
||||||
|
clockOutTime?: string;
|
||||||
|
breakStartTime?: string;
|
||||||
|
scheduledStart?: string;
|
||||||
|
scheduledEnd?: string;
|
||||||
|
isLate?: boolean;
|
||||||
|
leftEarly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimeClock: React.FC = () => {
|
||||||
|
const [employees, setEmployees] = useState<EmployeeStatus[]>([]);
|
||||||
|
const [selectedEmployee, setSelectedEmployee] = useState<EmployeeStatus | null>(null);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
||||||
|
// Update current time every second
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch employees and their status
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmployees();
|
||||||
|
const interval = setInterval(fetchEmployees, 30000); // Refresh every 30 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchEmployees = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3001/api/timeclock/status');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setEmployees(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employee status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmployeeClick = (employee: EmployeeStatus) => {
|
||||||
|
setSelectedEmployee(employee);
|
||||||
|
setPassword('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClockAction = async () => {
|
||||||
|
if (!selectedEmployee || !password) {
|
||||||
|
setError('Please enter your password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine action based on current status
|
||||||
|
let action: string;
|
||||||
|
if (selectedEmployee.currentStatus === 'clocked_out') {
|
||||||
|
action = 'clock_in';
|
||||||
|
} else if (selectedEmployee.currentStatus === 'on_break') {
|
||||||
|
action = 'break_end'; // End break first, then they can clock out
|
||||||
|
} else {
|
||||||
|
action = 'clock_out';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:3001/api/timeclock/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
employeeId: selectedEmployee.id,
|
||||||
|
password,
|
||||||
|
action,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSelectedEmployee(null);
|
||||||
|
setPassword('');
|
||||||
|
// Small delay to ensure database is updated
|
||||||
|
setTimeout(async () => {
|
||||||
|
await fetchEmployees(); // Refresh status
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Authentication failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeBarPosition = (time: string | undefined): number => {
|
||||||
|
if (!time) return 0;
|
||||||
|
|
||||||
|
// Handle both "HH:mm" and "HH:mm:ss" formats
|
||||||
|
const timeOnly = time.includes(' ') ? time.split(' ')[1] : time;
|
||||||
|
const [hours, minutes] = timeOnly.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + (minutes || 0);
|
||||||
|
const startMinutes = 8 * 60; // 8:00 AM
|
||||||
|
const endMinutes = 18 * 60; // 6:00 PM
|
||||||
|
const totalRange = endMinutes - startMinutes;
|
||||||
|
|
||||||
|
if (totalMinutes < startMinutes) return 0;
|
||||||
|
if (totalMinutes > endMinutes) return 100;
|
||||||
|
|
||||||
|
return ((totalMinutes - startMinutes) / totalRange) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentTimePosition = (): number => {
|
||||||
|
const hours = currentTime.getHours();
|
||||||
|
const minutes = currentTime.getMinutes();
|
||||||
|
const totalMinutes = hours * 60 + minutes;
|
||||||
|
const startMinutes = 8 * 60;
|
||||||
|
const endMinutes = 18 * 60;
|
||||||
|
const totalRange = endMinutes - startMinutes;
|
||||||
|
|
||||||
|
if (totalMinutes < startMinutes) return 0;
|
||||||
|
if (totalMinutes > endMinutes) return 100;
|
||||||
|
|
||||||
|
return ((totalMinutes - startMinutes) / totalRange) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (employee: EmployeeStatus): string => {
|
||||||
|
if (employee.currentStatus === 'clocked_in') return 'bg-green-500';
|
||||||
|
if (employee.currentStatus === 'on_break') return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (employee: EmployeeStatus): string => {
|
||||||
|
if (employee.currentStatus === 'clocked_in') return 'Clocked In';
|
||||||
|
if (employee.currentStatus === 'on_break') return 'On Break';
|
||||||
|
return 'Clocked Out';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: string | undefined): string => {
|
||||||
|
if (!time) return '';
|
||||||
|
try {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(h, m || 0, 0);
|
||||||
|
return format(date, 'h:mm a');
|
||||||
|
} catch {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">Time Clock</h1>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
{format(currentTime, 'EEEE, MMMM d, yyyy')} • {format(currentTime, 'h:mm:ss a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-3xl font-bold text-primary-600">
|
||||||
|
<Clock className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employee Grid - Large, clear blocks */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{employees.map((employee) => {
|
||||||
|
const isClockedIn = employee.currentStatus === 'clocked_in' || employee.currentStatus === 'on_break';
|
||||||
|
const currentPosition = getCurrentTimePosition();
|
||||||
|
const clockInPosition = employee.clockInTime ? getTimeBarPosition(employee.clockInTime) : 100;
|
||||||
|
const clockOutPosition = employee.clockOutTime ? getTimeBarPosition(employee.clockOutTime) : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={employee.id}
|
||||||
|
onClick={() => handleEmployeeClick(employee)}
|
||||||
|
className="bg-white rounded-xl shadow-lg border-2 border-gray-300 hover:border-primary-500 hover:shadow-xl transition-all cursor-pointer p-6"
|
||||||
|
>
|
||||||
|
{/* Employee Name - Large and prominent */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 mb-2">{employee.name}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-4 h-4 rounded-full ${getStatusColor(employee)}`}></div>
|
||||||
|
<span className="text-base font-semibold text-gray-700">{getStatusText(employee)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Bar Container - Large and clear */}
|
||||||
|
<div className="relative h-16 bg-red-500 rounded-lg overflow-hidden border-2 border-gray-300">
|
||||||
|
{/* Hour markers */}
|
||||||
|
<div className="absolute inset-0 flex">
|
||||||
|
{Array.from({ length: 11 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 border-r-2 border-red-600"
|
||||||
|
style={{ width: `${100 / 11}%` }}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hour labels at bottom */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex text-xs font-medium text-white bg-red-600 py-1">
|
||||||
|
{Array.from({ length: 11 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 text-center"
|
||||||
|
style={{ width: `${100 / 11}%` }}
|
||||||
|
>
|
||||||
|
{8 + i}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Green bar for clocked in period - always show if they clocked in today */}
|
||||||
|
{employee.clockInTime && (
|
||||||
|
<>
|
||||||
|
{employee.clockOutTime ? (
|
||||||
|
// Employee clocked in and out - green from clock in to clock out (historical)
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-5"
|
||||||
|
style={{
|
||||||
|
left: `${clockInPosition}%`,
|
||||||
|
width: `${clockOutPosition - clockInPosition}%`,
|
||||||
|
minWidth: '1%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Employee clocked in but not out - green from clock in to current time
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-5"
|
||||||
|
style={{
|
||||||
|
left: `${clockInPosition}%`,
|
||||||
|
width: `${currentPosition - clockInPosition}%`,
|
||||||
|
minWidth: '2%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current time indicator */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-gray-900 z-10"
|
||||||
|
style={{ left: `${currentPosition}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 w-4 h-4 bg-gray-900 rounded-full border-2 border-white"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Late indicator */}
|
||||||
|
{employee.isLate && employee.scheduledStart && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-6 w-2 bg-red-700 z-20 border-l-2 border-red-900"
|
||||||
|
style={{ left: `${getTimeBarPosition(employee.scheduledStart)}%` }}
|
||||||
|
title="Late arrival"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Info */}
|
||||||
|
<div className="mt-4 space-y-1 text-sm">
|
||||||
|
{employee.clockInTime && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Clocked In:</span>
|
||||||
|
<span className="font-semibold text-gray-900">{formatTime(employee.clockInTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{employee.clockOutTime && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Clocked Out:</span>
|
||||||
|
<span className="font-semibold text-gray-900">{formatTime(employee.clockOutTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{employee.scheduledStart && (
|
||||||
|
<div className={`flex justify-between ${employee.isLate ? 'text-red-600 font-semibold' : 'text-gray-600'}`}>
|
||||||
|
<span>Scheduled:</span>
|
||||||
|
<span className="font-semibold">{formatTime(employee.scheduledStart)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{employee.isLate && (
|
||||||
|
<div className="text-red-600 font-semibold text-center mt-2">
|
||||||
|
⚠️ Late Arrival
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Click hint */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 text-center">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Click to {
|
||||||
|
employee.currentStatus === 'clocked_out' ? 'Clock In' :
|
||||||
|
employee.currentStatus === 'on_break' ? 'End Break' :
|
||||||
|
'Clock Out'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Modal */}
|
||||||
|
{selectedEmployee && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-8 w-full max-w-md">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
{selectedEmployee.currentStatus === 'clocked_out' ? 'Clock In' :
|
||||||
|
selectedEmployee.currentStatus === 'on_break' ? 'End Break' :
|
||||||
|
'Clock Out'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600">
|
||||||
|
{selectedEmployee.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border-2 border-red-200 rounded-lg text-red-800 font-medium">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-lg font-semibold text-gray-700 mb-3">
|
||||||
|
Enter Your Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-4 top-1/2 transform -translate-y-1/2 w-6 h-6 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleClockAction()}
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-12 pr-4 py-4 text-lg border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmployee(null);
|
||||||
|
setPassword('');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className="flex-1 px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-semibold hover:bg-gray-300 text-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClockAction}
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
selectedEmployee.currentStatus === 'clocked_out' ? 'Clock In' :
|
||||||
|
selectedEmployee.currentStatus === 'on_break' ? 'End Break' :
|
||||||
|
'Clock Out'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{employees.length === 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
|
<Clock className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-xl text-gray-600">No employees found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Make sure the backend server is running</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
179
src/pages/Timecards.tsx
Normal file
179
src/pages/Timecards.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Clock, CheckCircle, XCircle, AlertCircle, Calendar } from 'lucide-react';
|
||||||
|
import { format, startOfWeek, addDays, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
export const Timecards: React.FC = () => {
|
||||||
|
const [view, setView] = useState<'weekly' | 'monthly'>('weekly');
|
||||||
|
const [selectedDate] = useState(new Date());
|
||||||
|
|
||||||
|
const timecards = [
|
||||||
|
{ date: new Date(2023, 11, 11), clockIn: '8:02 AM', clockOut: '5:14 PM', breakTime: '1h', totalHours: 8, status: 'approved' },
|
||||||
|
{ date: new Date(2023, 11, 12), clockIn: '8:00 AM', clockOut: '5:00 PM', breakTime: '1h', totalHours: 8, status: 'approved' },
|
||||||
|
{ date: new Date(2023, 11, 13), clockIn: '8:15 AM', clockOut: '5:30 PM', breakTime: '1h', totalHours: 8.25, status: 'pending' },
|
||||||
|
{ date: new Date(2023, 11, 14), clockIn: '8:00 AM', clockOut: null, breakTime: '0h', totalHours: 4, status: 'flagged' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weekStart = startOfWeek(selectedDate);
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'flagged':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <CheckCircle className="w-4 h-4" />;
|
||||||
|
case 'pending':
|
||||||
|
return <AlertCircle className="w-4 h-4" />;
|
||||||
|
case 'flagged':
|
||||||
|
return <XCircle className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">My Timecards</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('weekly')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
view === 'weekly' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('monthly')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
view === 'monthly' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Summary Card */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Weekly Summary</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Total Hours</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">32.0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Overtime</p>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">0.0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Break Time</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">5.0h</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Approval Status</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-sm font-medium">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'weekly' ? (
|
||||||
|
<>
|
||||||
|
{/* Daily Breakdown */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Daily Breakdown</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{timecards.map((tc, idx) => (
|
||||||
|
<div key={idx} className={`p-4 rounded-lg border ${getStatusColor(tc.status)}`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{format(tc.date, 'EEEE, MMM d, yyyy')}</span>
|
||||||
|
{getStatusIcon(tc.status)}
|
||||||
|
</div>
|
||||||
|
<button className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Request Fix
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Clock In</p>
|
||||||
|
<p className="font-medium">{tc.clockIn}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Break</p>
|
||||||
|
<p className="font-medium">{tc.breakTime}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Clock Out</p>
|
||||||
|
<p className="font-medium">{tc.clockOut || 'Missing'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Total Hours</p>
|
||||||
|
<p className="font-medium">{tc.totalHours}h</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar View */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Calendar View</h2>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{weekDays.map((day, idx) => {
|
||||||
|
const tc = timecards.find(t => isSameDay(t.date, day));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
tc
|
||||||
|
? getStatusColor(tc.status)
|
||||||
|
: 'bg-gray-50 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium mb-1">{format(day, 'EEE')}</p>
|
||||||
|
<p className="text-lg font-bold mb-2">{format(day, 'd')}</p>
|
||||||
|
{tc && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-medium">{tc.totalHours}h</p>
|
||||||
|
{tc.status === 'approved' && <CheckCircle className="w-3 h-3 mt-1" />}
|
||||||
|
{tc.status === 'pending' && <AlertCircle className="w-3 h-3 mt-1" />}
|
||||||
|
{tc.status === 'flagged' && <XCircle className="w-3 h-3 mt-1" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Monthly View</h2>
|
||||||
|
<p className="text-gray-600">Monthly calendar view would be displayed here</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
258
src/pages/UserManagement.tsx
Normal file
258
src/pages/UserManagement.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Layout } from '../components/Layout/Layout';
|
||||||
|
import { Users, Edit, Trash2, Plus, Search, Filter, Save, X } from 'lucide-react';
|
||||||
|
import { mockDatabase } from '../data/mockDatabase';
|
||||||
|
import { User, UserRole } from '../types';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export const UserManagement: React.FC = () => {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: 'employee' as UserRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsers = () => {
|
||||||
|
setUsers(mockDatabase.getUsers());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setFormData({ name: '', email: '', role: 'employee' });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setFormData({ name: user.name, email: user.email, role: user.role });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this user?')) {
|
||||||
|
if (mockDatabase.deleteUser(id)) {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (editingUser) {
|
||||||
|
// Update existing user
|
||||||
|
const updated = mockDatabase.updateUser(editingUser.id, formData);
|
||||||
|
if (updated) {
|
||||||
|
loadUsers();
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
const newUser = mockDatabase.createUser(formData);
|
||||||
|
if (newUser) {
|
||||||
|
loadUsers();
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({ name: '', email: '', role: 'employee' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(u =>
|
||||||
|
u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">User & Role Management</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 flex items-center gap-2 bg-gray-100 rounded-lg px-4 py-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-transparent border-none outline-none flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{user.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
|
||||||
|
user.role === 'hr' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
user.role === 'payroll' ? 'bg-green-100 text-green-800' :
|
||||||
|
user.role === 'manager' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{user.role.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
className="text-primary-600 hover:text-primary-900"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{user.id !== currentUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
|
{editingUser ? 'Edit User' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="employee">Employee</option>
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="hr">HR</option>
|
||||||
|
<option value="payroll">Payroll</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{editingUser ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
70
src/types.ts
Normal file
70
src/types.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
export type UserRole = 'employee' | 'manager' | 'hr' | 'payroll' | 'admin';
|
||||||
|
|
||||||
|
export type ClockStatus = 'clocked_out' | 'clocked_in' | 'on_break';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Timecard {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
date: string;
|
||||||
|
clockIn?: string;
|
||||||
|
clockOut?: string;
|
||||||
|
breakStart?: string;
|
||||||
|
breakEnd?: string;
|
||||||
|
totalHours: number;
|
||||||
|
overtime: number;
|
||||||
|
status: 'pending' | 'approved' | 'flagged';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shift {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
role: string;
|
||||||
|
location: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisciplinaryAction {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
date: string;
|
||||||
|
details: string;
|
||||||
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
status: 'draft' | 'pending_approval' | 'finalized';
|
||||||
|
documentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
id: string;
|
||||||
|
vendor: string;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
tax: number;
|
||||||
|
category: string;
|
||||||
|
notes?: string;
|
||||||
|
status: 'pending' | 'needs_review' | 'approved';
|
||||||
|
ocrConfidence?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayrollRun {
|
||||||
|
id: string;
|
||||||
|
payPeriod: string;
|
||||||
|
status: 'preview' | 'pending' | 'completed';
|
||||||
|
totalHours: number;
|
||||||
|
totalGross: number;
|
||||||
|
totalNet: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
28
tailwind.config.js
Normal file
28
tailwind.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user