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