Compare commits
7 Commits
main
...
v2.0-featu
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ac7cabe92 | |||
| 1d1bb79928 | |||
| 7580162816 | |||
| 86f3fa7f1d | |||
| 2f7791989c | |||
| 641544f717 | |||
| 2caf98ad0f |
@ -49,6 +49,9 @@ coverage
|
|||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Explicitly include start.sh
|
||||||
|
!start.sh
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|||||||
30
.env.dokploy
30
.env.dokploy
@ -1,30 +0,0 @@
|
|||||||
# Dokploy Environment Variables
|
|
||||||
# Use these values in your Dokploy environment variables section
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
APP_PORT=5173
|
|
||||||
|
|
||||||
# Database (uses Docker volume)
|
|
||||||
DATABASE_URL=file:/app/data/production.db
|
|
||||||
|
|
||||||
# Security - CHANGE THESE VALUES!
|
|
||||||
SESSION_SECRET=your-super-secure-session-secret-change-this-in-production-min-32-chars
|
|
||||||
ENCRYPTION_KEY=production-secure-encryption-key!
|
|
||||||
SUPER_ADMIN=superadmin
|
|
||||||
SUPER_ADMIN_EMAIL=admin@yourcompany.com
|
|
||||||
SUPER_ADMIN_PASSWORD=YourSecurePassword123!
|
|
||||||
|
|
||||||
# Domain (set to your actual domain)
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
|
|
||||||
# Mail Settings (optional - for password reset features)
|
|
||||||
MAIL_HOST=
|
|
||||||
MAIL_PORT=587
|
|
||||||
MAIL_SECURE=false
|
|
||||||
MAIL_USERNAME=
|
|
||||||
MAIL_PASSWORD=
|
|
||||||
MAIL_FROM_NAME=Phosphat Report System
|
|
||||||
MAIL_FROM_EMAIL=
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=info
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# Production Environment Variables
|
|
||||||
# Copy this file and rename to .env for production deployment
|
|
||||||
# Make sure to change all default values for security
|
|
||||||
|
|
||||||
# Application Settings
|
|
||||||
NODE_ENV=production
|
|
||||||
APP_PORT=5173
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL="file:/app/data/production.db"
|
|
||||||
|
|
||||||
# Security
|
|
||||||
SESSION_SECRET="your-super-secure-session-secret-change-this-in-production-min-32-chars"
|
|
||||||
ENCRYPTION_KEY="production-secure-encryption-key!"
|
|
||||||
|
|
||||||
# Super Admin Account (created on first run)
|
|
||||||
SUPER_ADMIN="superadmin"
|
|
||||||
SUPER_ADMIN_EMAIL="admin@yourcompany.com"
|
|
||||||
SUPER_ADMIN_PASSWORD="YourSecurePassword123!"
|
|
||||||
|
|
||||||
# Storage Paths (for bind mounts)
|
|
||||||
DATA_PATH=./data
|
|
||||||
BACKUP_PATH=./backups
|
|
||||||
|
|
||||||
# Backup Schedule (cron format)
|
|
||||||
BACKUP_SCHEDULE="0 2 * * *"
|
|
||||||
|
|
||||||
# Mail Settings (optional - for password reset features)
|
|
||||||
MAIL_HOST=""
|
|
||||||
MAIL_PORT="587"
|
|
||||||
MAIL_SECURE="false"
|
|
||||||
MAIL_USERNAME=""
|
|
||||||
MAIL_PASSWORD=""
|
|
||||||
MAIL_FROM_NAME="Phosphat Report System"
|
|
||||||
MAIL_FROM_EMAIL=""
|
|
||||||
|
|
||||||
# Logging (optional)
|
|
||||||
LOG_LEVEL="info"
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
# Carry Forward Feature - Implementation Complete
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
The carry-forward feature automatically populates form fields in the new report form with calculated values from the last report for the same location combination (Area, Dredger Location, and Reclamation Location).
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### API Endpoint
|
|
||||||
**File:** `app/routes/api.last-report-data.ts`
|
|
||||||
|
|
||||||
Fetches the most recent report for a given location combination and calculates carry-forward values:
|
|
||||||
|
|
||||||
- **Dredger Line Length**: Same as last report
|
|
||||||
- **Shore Connection**: Same as last report
|
|
||||||
- **Reclamation Height Base**: (Base Height + Extra Height) from last report
|
|
||||||
- **Pipeline Main**: (Main + Extension 1) from last report
|
|
||||||
- **Pipeline Reserve**: (Reserve + Extension 2) from last report
|
|
||||||
|
|
||||||
### Frontend Integration
|
|
||||||
**File:** `app/routes/reports_.new.tsx`
|
|
||||||
|
|
||||||
#### useEffect Hook
|
|
||||||
Added a `useEffect` that triggers when:
|
|
||||||
- User reaches Step 2
|
|
||||||
- All three location fields are selected (Area, Dredger Location, Reclamation Location)
|
|
||||||
|
|
||||||
The hook:
|
|
||||||
1. Fetches last report data from the API
|
|
||||||
2. Only updates fields that are still at their default values
|
|
||||||
3. Preserves any user-modified values
|
|
||||||
|
|
||||||
#### User Experience
|
|
||||||
- Info message displayed in Step 2 explaining that values are auto-filled
|
|
||||||
- Users can modify any auto-filled values
|
|
||||||
- Only applies to fields that haven't been manually changed
|
|
||||||
- Gracefully handles cases where no previous report exists
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
### When Values Are Carried Forward
|
|
||||||
- User selects Area, Dredger Location, and Dredger Line Length in Step 1
|
|
||||||
- User moves to Step 2 and selects Reclamation Location
|
|
||||||
- System automatically fetches and populates:
|
|
||||||
- Dredger Line Length (if not already entered)
|
|
||||||
- Shore Connection (if empty)
|
|
||||||
- Reclamation Height Base (if still at default 0)
|
|
||||||
- Pipeline Main (if still at default 0)
|
|
||||||
- Pipeline Reserve (if still at default 0)
|
|
||||||
|
|
||||||
### When Values Are NOT Carried Forward
|
|
||||||
- No previous report exists for the location combination
|
|
||||||
- User has already manually entered values
|
|
||||||
- API request fails (fails silently, doesn't block user)
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
1. **Efficiency**: Reduces data entry time for recurring locations
|
|
||||||
2. **Accuracy**: Ensures continuity of measurements across shifts
|
|
||||||
3. **User-Friendly**: Non-intrusive - users can still override any value
|
|
||||||
4. **Smart**: Only updates fields that haven't been touched by the user
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
- Uses GET request to `/api/last-report-data` with query parameters
|
|
||||||
- Calculations performed server-side for data integrity
|
|
||||||
- Client-side state management prevents overwriting user input
|
|
||||||
- Error handling ensures feature doesn't break form functionality
|
|
||||||
147
DEPLOYMENT_GUIDE.md
Normal file
147
DEPLOYMENT_GUIDE.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# Deployment Guide for Phosphat Report App
|
||||||
|
|
||||||
|
This guide will help you deploy the Phosphat Report application on your VPS using the provided `compose.yml` file.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed on your VPS
|
||||||
|
- Git (to clone the repository)
|
||||||
|
- At least 1GB RAM and 10GB disk space
|
||||||
|
|
||||||
|
## Quick Deployment
|
||||||
|
|
||||||
|
1. **Clone the repository** to your VPS:
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd phosphat-report-app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deploy the application**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check the status**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access your application**:
|
||||||
|
- URL: `http://your-vps-ip:3000`
|
||||||
|
- Default login: `superadmin` / `P@ssw0rd123!`
|
||||||
|
|
||||||
|
## Environment Variables (Hardcoded in compose.yml)
|
||||||
|
|
||||||
|
The following environment variables are already configured in the `compose.yml` file:
|
||||||
|
|
||||||
|
- **NODE_ENV**: `production`
|
||||||
|
- **DATABASE_URL**: `file:/app/data/production.db`
|
||||||
|
- **SESSION_SECRET**: `your-super-secure-session-secret-change-this-min-32-chars`
|
||||||
|
- **SUPER_ADMIN**: `superadmin`
|
||||||
|
- **SUPER_ADMIN_EMAIL**: `admin@yourcompany.com`
|
||||||
|
- **SUPER_ADMIN_PASSWORD**: `P@ssw0rd123!`
|
||||||
|
- **MAIL_HOST**: `smtp.gmail.com`
|
||||||
|
- **MAIL_PORT**: `587`
|
||||||
|
- **MAIL_USERNAME**: `your-email@gmail.com`
|
||||||
|
- **MAIL_PASSWORD**: `your-app-password`
|
||||||
|
|
||||||
|
## Services Included
|
||||||
|
|
||||||
|
### Main Application (`app`)
|
||||||
|
- **Port**: 3000
|
||||||
|
- **Database**: SQLite with persistent storage
|
||||||
|
- **Health Check**: Available at `/health` endpoint
|
||||||
|
- **Resource Limits**: 512MB RAM, 0.5 CPU
|
||||||
|
|
||||||
|
### Backup Service (`backup`)
|
||||||
|
- **Purpose**: Automatic daily database backups at 2 AM
|
||||||
|
- **Retention**: Keeps backups for 7 days
|
||||||
|
- **Location**: `/backup` volume
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### View logs:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop services:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart services:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual backup:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml exec app cp /app/data/production.db /app/data/backup_$(date +%Y%m%d_%H%M%S).db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check health:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
- **app_data**: Stores the SQLite database
|
||||||
|
- **app_logs**: Application logs
|
||||||
|
- **backup_data**: Database backups
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **Change default passwords** after first login
|
||||||
|
2. **Update email settings** in the application
|
||||||
|
3. **Configure firewall** to only allow necessary ports
|
||||||
|
4. **Use HTTPS** with a reverse proxy (nginx/traefik) for production
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Application won't start:
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker-compose -f compose.yml logs app
|
||||||
|
|
||||||
|
# Rebuild without cache
|
||||||
|
docker-compose -f compose.yml build --no-cache app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database issues:
|
||||||
|
```bash
|
||||||
|
# Reset database (WARNING: This will delete all data)
|
||||||
|
docker-compose -f compose.yml down
|
||||||
|
docker volume rm $(docker volume ls -q | grep app_data)
|
||||||
|
docker-compose -f compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts:
|
||||||
|
If port 3000 is already in use, edit the `compose.yml` file and change:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "3001:3000" # Change 3000 to any available port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the Application
|
||||||
|
|
||||||
|
1. **Pull latest changes**:
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rebuild and restart**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and support:
|
||||||
|
1. Check the application logs
|
||||||
|
2. Verify all services are running
|
||||||
|
3. Test the health endpoint
|
||||||
|
4. Check database connectivity
|
||||||
|
|
||||||
|
The application should be accessible at `http://your-vps-ip:3000` after successful deployment.
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# Edit Report Implementation Summary
|
|
||||||
|
|
||||||
## What Was Done
|
|
||||||
|
|
||||||
I've started creating a dedicated edit route at `app/routes/reports_.$id.edit.tsx` that mirrors the structure of `reports_.new.tsx` but with the following key differences:
|
|
||||||
|
|
||||||
### Locked Fields (Cannot be edited):
|
|
||||||
1. **Report Date** - Displayed as read-only
|
|
||||||
2. **Shift** - Displayed as read-only badge
|
|
||||||
3. **Area** - Displayed as read-only
|
|
||||||
4. **Dredger Location** - Displayed as read-only
|
|
||||||
5. **Reclamation Location** - Displayed as read-only
|
|
||||||
|
|
||||||
### Editable Fields (3 Steps):
|
|
||||||
|
|
||||||
**Step 1: Pipeline Details**
|
|
||||||
- Dredger Line Length
|
|
||||||
- Shore Connection
|
|
||||||
- Reclamation Height (Base & Extra)
|
|
||||||
- Pipeline Length (Main, Ext1, Reserve, Ext2)
|
|
||||||
|
|
||||||
**Step 2: Equipment & Time Sheet**
|
|
||||||
- Equipment Statistics (Auto-calculated from timesheet)
|
|
||||||
- Foreman selection
|
|
||||||
- Workers selection (with autocomplete)
|
|
||||||
- Time Sheet entries
|
|
||||||
|
|
||||||
**Step 3: Stoppages & Notes**
|
|
||||||
- Stoppage entries
|
|
||||||
- Additional notes
|
|
||||||
|
|
||||||
## Next Steps Required
|
|
||||||
|
|
||||||
Due to file size limitations, the implementation needs to be completed with:
|
|
||||||
|
|
||||||
1. **Helper Functions** (same as new report):
|
|
||||||
- `calculateTimeDifference`
|
|
||||||
- `calculateStoppageTime`
|
|
||||||
- Time sheet management functions
|
|
||||||
- Stoppage management functions
|
|
||||||
- Worker selection functions
|
|
||||||
|
|
||||||
2. **JSX Render** with 3 steps showing:
|
|
||||||
- Locked fields display at the top
|
|
||||||
- Step 1: Pipeline details form
|
|
||||||
- Step 2: Equipment & timesheet
|
|
||||||
- Step 3: Stoppages & notes
|
|
||||||
- Navigation buttons
|
|
||||||
|
|
||||||
3. **Update reports.tsx** to change Edit button:
|
|
||||||
```tsx
|
|
||||||
<Link
|
|
||||||
to={`/reports/${report.id}/edit`}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
- Route: `/reports/:id/edit`
|
|
||||||
- File: `app/routes/reports_.$id.edit.tsx`
|
|
||||||
- Uses same multi-step wizard as new report
|
|
||||||
- Preserves all existing data
|
|
||||||
- Updates workers via ShiftWorker table
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
- Regular users (level 1): Can only edit their latest report
|
|
||||||
- Supervisors/Admins (level 2+): Can edit any report
|
|
||||||
- Cannot change core identifying fields (date, shift, locations)
|
|
||||||
@ -1,405 +0,0 @@
|
|||||||
# ✅ IMPLEMENTED - Edit Route Complete
|
|
||||||
|
|
||||||
## Implementation Status: COMPLETE
|
|
||||||
|
|
||||||
The edit route has been successfully transformed from the guide into a fully working implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Complete Edit Route Implementation Guide
|
|
||||||
|
|
||||||
## File: `app/routes/reports_.$id.edit.tsx`
|
|
||||||
|
|
||||||
Since the file is too large to modify in one operation, here are ALL the changes needed to transform `reports_.new.tsx` into a working edit route:
|
|
||||||
|
|
||||||
### 1. Update Meta Function
|
|
||||||
```typescript
|
|
||||||
export const meta: MetaFunction = () => [{ title: "Edit Report - Phosphat Report" }];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update Loader Function
|
|
||||||
Replace the entire loader with:
|
|
||||||
```typescript
|
|
||||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
||||||
const user = await requireAuthLevel(request, 1);
|
|
||||||
const reportId = params.id;
|
|
||||||
|
|
||||||
if (!reportId) {
|
|
||||||
throw new Response("Report ID is required", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the report to edit
|
|
||||||
const report = await prisma.report.findUnique({
|
|
||||||
where: { id: parseInt(reportId) },
|
|
||||||
include: {
|
|
||||||
employee: { select: { name: true } },
|
|
||||||
area: { select: { name: true } },
|
|
||||||
dredgerLocation: { select: { name: true, class: true } },
|
|
||||||
reclamationLocation: { select: { name: true } },
|
|
||||||
shiftWorkers: {
|
|
||||||
include: {
|
|
||||||
worker: { select: { id: true, name: true, status: true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
throw new Response("Report not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permissions
|
|
||||||
if (user.authLevel < 2 && report.employeeId !== user.id) {
|
|
||||||
throw new Response("You can only edit your own reports", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.authLevel < 2) {
|
|
||||||
const latestUserReport = await prisma.report.findFirst({
|
|
||||||
where: { employeeId: user.id },
|
|
||||||
orderBy: { createdDate: 'desc' },
|
|
||||||
select: { id: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latestUserReport || latestUserReport.id !== parseInt(reportId)) {
|
|
||||||
throw new Response("You can only edit your latest report", { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get dropdown data for form
|
|
||||||
const [areas, dredgerLocations, reclamationLocations, foremen, equipment, workers] = await Promise.all([
|
|
||||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
|
||||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
|
||||||
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
|
|
||||||
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
|
|
||||||
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
|
|
||||||
prisma.worker.findMany({ where: { status: 'active' }, orderBy: { name: 'asc' } })
|
|
||||||
]);
|
|
||||||
|
|
||||||
return json({
|
|
||||||
user,
|
|
||||||
report,
|
|
||||||
areas,
|
|
||||||
dredgerLocations,
|
|
||||||
reclamationLocations,
|
|
||||||
foremen,
|
|
||||||
equipment,
|
|
||||||
workers
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Action Function
|
|
||||||
Replace the entire action with:
|
|
||||||
```typescript
|
|
||||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|
||||||
const user = await requireAuthLevel(request, 1);
|
|
||||||
const reportId = params.id;
|
|
||||||
|
|
||||||
if (!reportId) {
|
|
||||||
return json({ errors: { form: "Report ID is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingReport = await prisma.report.findUnique({
|
|
||||||
where: { id: parseInt(reportId) },
|
|
||||||
select: {
|
|
||||||
employeeId: true,
|
|
||||||
createdDate: true,
|
|
||||||
shift: true,
|
|
||||||
areaId: true,
|
|
||||||
dredgerLocationId: true,
|
|
||||||
reclamationLocationId: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingReport) {
|
|
||||||
return json({ errors: { form: "Report not found" } }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.authLevel < 2) {
|
|
||||||
if (existingReport.employeeId !== user.id) {
|
|
||||||
return json({ errors: { form: "You can only edit your own reports" } }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestUserReport = await prisma.report.findFirst({
|
|
||||||
where: { employeeId: user.id },
|
|
||||||
orderBy: { createdDate: 'desc' },
|
|
||||||
select: { id: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latestUserReport || latestUserReport.id !== parseInt(reportId)) {
|
|
||||||
return json({ errors: { form: "You can only edit your latest report" } }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
|
|
||||||
const dredgerLineLength = formData.get("dredgerLineLength");
|
|
||||||
const shoreConnection = formData.get("shoreConnection");
|
|
||||||
const notes = formData.get("notes");
|
|
||||||
const reclamationHeightBase = formData.get("reclamationHeightBase");
|
|
||||||
const reclamationHeightExtra = formData.get("reclamationHeightExtra");
|
|
||||||
const pipelineMain = formData.get("pipelineMain");
|
|
||||||
const pipelineExt1 = formData.get("pipelineExt1");
|
|
||||||
const pipelineReserve = formData.get("pipelineReserve");
|
|
||||||
const pipelineExt2 = formData.get("pipelineExt2");
|
|
||||||
const statsDozers = formData.get("statsDozers");
|
|
||||||
const statsExc = formData.get("statsExc");
|
|
||||||
const statsLoaders = formData.get("statsLoaders");
|
|
||||||
const statsForeman = formData.get("statsForeman");
|
|
||||||
const statsLaborer = formData.get("statsLaborer");
|
|
||||||
const workersListData = formData.get("workersList");
|
|
||||||
const timeSheetData = formData.get("timeSheetData");
|
|
||||||
const stoppagesData = formData.get("stoppagesData");
|
|
||||||
|
|
||||||
if (typeof dredgerLineLength !== "string" || isNaN(parseInt(dredgerLineLength))) {
|
|
||||||
return json({ errors: { dredgerLineLength: "Valid dredger line length is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof shoreConnection !== "string" || isNaN(parseInt(shoreConnection))) {
|
|
||||||
return json({ errors: { shoreConnection: "Valid shore connection is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let timeSheet = [];
|
|
||||||
let stoppages = [];
|
|
||||||
let workersList = [];
|
|
||||||
|
|
||||||
if (timeSheetData && typeof timeSheetData === "string") {
|
|
||||||
try { timeSheet = JSON.parse(timeSheetData); } catch (e) { timeSheet = []; }
|
|
||||||
}
|
|
||||||
if (stoppagesData && typeof stoppagesData === "string") {
|
|
||||||
try { stoppages = JSON.parse(stoppagesData); } catch (e) { stoppages = []; }
|
|
||||||
}
|
|
||||||
if (workersListData && typeof workersListData === "string") {
|
|
||||||
try { workersList = JSON.parse(workersListData); } catch (e) { workersList = []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext1Value = parseInt(pipelineExt1 as string) || 0;
|
|
||||||
const ext2Value = parseInt(pipelineExt2 as string) || 0;
|
|
||||||
const shiftText = existingReport.shift === 'day' ? 'Day' : 'Night';
|
|
||||||
|
|
||||||
let automaticNotes = [];
|
|
||||||
if (ext1Value > 0) automaticNotes.push(`Main Extension ${ext1Value}m ${shiftText}`);
|
|
||||||
if (ext2Value > 0) automaticNotes.push(`Reserve Extension ${ext2Value}m ${shiftText}`);
|
|
||||||
|
|
||||||
let finalNotes = notes || '';
|
|
||||||
if (automaticNotes.length > 0) {
|
|
||||||
const automaticNotesText = automaticNotes.join(', ');
|
|
||||||
finalNotes = finalNotes.trim() ? `${automaticNotesText}. ${finalNotes}` : automaticNotesText;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.report.update({
|
|
||||||
where: { id: parseInt(reportId) },
|
|
||||||
data: {
|
|
||||||
dredgerLineLength: parseInt(dredgerLineLength),
|
|
||||||
shoreConnection: parseInt(shoreConnection),
|
|
||||||
reclamationHeight: {
|
|
||||||
base: parseInt(reclamationHeightBase as string) || 0,
|
|
||||||
extra: parseInt(reclamationHeightExtra as string) || 0
|
|
||||||
},
|
|
||||||
pipelineLength: {
|
|
||||||
main: parseInt(pipelineMain as string) || 0,
|
|
||||||
ext1: ext1Value,
|
|
||||||
reserve: parseInt(pipelineReserve as string) || 0,
|
|
||||||
ext2: ext2Value
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
Dozers: parseInt(statsDozers as string) || 0,
|
|
||||||
Exc: parseInt(statsExc as string) || 0,
|
|
||||||
Loaders: parseInt(statsLoaders as string) || 0,
|
|
||||||
Foreman: statsForeman as string || "",
|
|
||||||
Laborer: workersList.length
|
|
||||||
},
|
|
||||||
timeSheet,
|
|
||||||
stoppages,
|
|
||||||
notes: finalNotes || null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update workers
|
|
||||||
await prisma.shiftWorker.deleteMany({ where: { reportId: parseInt(reportId) } });
|
|
||||||
if (workersList.length > 0) {
|
|
||||||
await prisma.shiftWorker.createMany({
|
|
||||||
data: workersList.map((workerId: number) => ({
|
|
||||||
reportId: parseInt(reportId),
|
|
||||||
workerId: workerId
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect("/reports?success=Report updated successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
return json({ errors: { form: "Failed to update report. Please try again." } }, { status: 400 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update Component - Change loader destructuring
|
|
||||||
```typescript
|
|
||||||
const { user, report, areas, dredgerLocations, reclamationLocations, foremen, equipment, workers } = useLoaderData<typeof loader>();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Initialize form data with existing report data
|
|
||||||
Replace the formData useState with:
|
|
||||||
```typescript
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
dredgerLineLength: report.dredgerLineLength.toString(),
|
|
||||||
shoreConnection: report.shoreConnection.toString(),
|
|
||||||
reclamationHeightBase: (report.reclamationHeight as any).base?.toString() || '0',
|
|
||||||
reclamationHeightExtra: (report.reclamationHeight as any).extra?.toString() || '0',
|
|
||||||
pipelineMain: (report.pipelineLength as any).main?.toString() || '0',
|
|
||||||
pipelineExt1: (report.pipelineLength as any).ext1?.toString() || '0',
|
|
||||||
pipelineReserve: (report.pipelineLength as any).reserve?.toString() || '0',
|
|
||||||
pipelineExt2: (report.pipelineLength as any).ext2?.toString() || '0',
|
|
||||||
statsDozers: (report.stats as any).Dozers?.toString() || '0',
|
|
||||||
statsExc: (report.stats as any).Exc?.toString() || '0',
|
|
||||||
statsLoaders: (report.stats as any).Loaders?.toString() || '0',
|
|
||||||
statsForeman: (report.stats as any).Foreman || '',
|
|
||||||
statsLaborer: (report.stats as any).Laborer?.toString() || '0',
|
|
||||||
notes: report.notes || ''
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Initialize workers with existing data
|
|
||||||
Replace selectedWorkers useState with:
|
|
||||||
```typescript
|
|
||||||
const [selectedWorkers, setSelectedWorkers] = useState<number[]>(
|
|
||||||
report.shiftWorkers?.map((sw: any) => sw.worker.id) || []
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Initialize timesheet and stoppages with existing data
|
|
||||||
Replace the useState declarations with:
|
|
||||||
```typescript
|
|
||||||
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{...}>>(
|
|
||||||
Array.isArray(report.timeSheet) ? (report.timeSheet as any[]).map((entry: any, index: number) => ({
|
|
||||||
...entry,
|
|
||||||
id: entry.id || `existing-${index}`
|
|
||||||
})) : []
|
|
||||||
);
|
|
||||||
|
|
||||||
const [stoppageEntries, setStoppageEntries] = useState<Array<{...}>>(
|
|
||||||
Array.isArray(report.stoppages) ? (report.stoppages as any[]).map((entry: any, index: number) => ({
|
|
||||||
...entry,
|
|
||||||
id: entry.id || `existing-${index}`
|
|
||||||
})) : []
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Change totalSteps to 3 (remove basic info step)
|
|
||||||
```typescript
|
|
||||||
const totalSteps = 3;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Update page title
|
|
||||||
```typescript
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Edit Report</h1>
|
|
||||||
<p className="mt-2 text-sm sm:text-base text-gray-600">Update shift details</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. Update back button
|
|
||||||
```typescript
|
|
||||||
<Link to="/reports" className="inline-flex items-center...">
|
|
||||||
Back to Reports
|
|
||||||
</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11. Replace Step 1 with Locked Fields Display + Pipeline Details
|
|
||||||
```typescript
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Locked Fields Display */}
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Report Information (Cannot be changed)</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-600">Date:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{new Date(report.createdDate).toLocaleDateString('en-GB')}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-600">Shift:</span>
|
|
||||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${report.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
|
|
||||||
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-600">Area:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{report.area.name}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-600">Dredger Location:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{report.dredgerLocation.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="font-medium text-gray-600">Reclamation Location:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{report.reclamationLocation.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editable Pipeline Details - Same as Step 2 from new report */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Dredger Line Length (m) <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="dredgerLineLength"
|
|
||||||
name="dredgerLineLength"
|
|
||||||
required
|
|
||||||
min="0"
|
|
||||||
value={formData.dredgerLineLength}
|
|
||||||
onChange={(e) => updateFormData('dredgerLineLength', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Shore Connection <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="shoreConnection"
|
|
||||||
name="shoreConnection"
|
|
||||||
required
|
|
||||||
min="0"
|
|
||||||
value={formData.shoreConnection}
|
|
||||||
onChange={(e) => updateFormData('shoreConnection', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Add Reclamation Height and Pipeline Length sections here - same as new report Step 2 */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12. Update step titles
|
|
||||||
```typescript
|
|
||||||
const getStepTitle = (step: number) => {
|
|
||||||
switch (step) {
|
|
||||||
case 1: return "Pipeline Details";
|
|
||||||
case 2: return "Equipment & Time Sheet";
|
|
||||||
case 3: return "Stoppages & Notes";
|
|
||||||
default: return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 13. Update submit button text
|
|
||||||
```typescript
|
|
||||||
<button type="submit" ...>
|
|
||||||
{isSubmitting ? 'Updating Report...' : 'Update Report'}
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14. Remove validation error state and duplicate check
|
|
||||||
Remove the `validationError` state and the duplicate check logic from `nextStep` function.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
The edit route is now a 3-step wizard that:
|
|
||||||
- Step 1: Shows locked fields + Pipeline details
|
|
||||||
- Step 2: Equipment & Time Sheet
|
|
||||||
- Step 3: Stoppages & Notes
|
|
||||||
|
|
||||||
All existing data is pre-populated and the user cannot change the core identifying fields (date, shift, locations).
|
|
||||||
@ -312,17 +312,6 @@ function SidebarContent({
|
|||||||
Foreman
|
Foreman
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|
||||||
<NavItem
|
|
||||||
to="/workers"
|
|
||||||
icon={
|
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Workers
|
|
||||||
</NavItem>
|
|
||||||
|
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/employees"
|
to="/employees"
|
||||||
icon={
|
icon={
|
||||||
|
|||||||
@ -694,9 +694,7 @@ function ReportSheetNotes({ report }: { report: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-2 border-black mb-4 min-h-[100px]">
|
<div className="border-2 border-black mb-4 min-h-[100px]">
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<pre>
|
|
||||||
{report.notes || 'No additional notes'}
|
{report.notes || 'No additional notes'}
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -474,10 +474,8 @@ function ReportNotes({ report }: { report: any }) {
|
|||||||
Notes & Comments
|
Notes & Comments
|
||||||
</div>
|
</div>
|
||||||
<div className="border-2 border-black mb-4 min-h-[100px]">
|
<div className="border-2 border-black mb-4 min-h-[100px]">
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<pre>
|
|
||||||
{report.notes || 'No additional notes'}
|
{report.notes || 'No additional notes'}
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -53,4 +53,4 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { json } from "@remix-run/node";
|
|
||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
|
||||||
import { prisma } from "~/utils/db.server";
|
|
||||||
import { requireAuthLevel } from "~/utils/auth.server";
|
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
||||||
await requireAuthLevel(request, 1);
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return json({ error: "Method not allowed" }, { status: 405 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createdDate, shift, areaId, dredgerLocationId, reclamationLocationId } = await request.json();
|
|
||||||
|
|
||||||
// Parse the date and set to start of day
|
|
||||||
const date = new Date(createdDate);
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const nextDay = new Date(date);
|
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
|
||||||
|
|
||||||
// Check if a report already exists with same date, shift, area, dredger location, and reclamation location
|
|
||||||
const existingReport = await prisma.report.findFirst({
|
|
||||||
where: {
|
|
||||||
createdDate: {
|
|
||||||
gte: date,
|
|
||||||
lt: nextDay
|
|
||||||
},
|
|
||||||
shift,
|
|
||||||
areaId: parseInt(areaId),
|
|
||||||
dredgerLocationId: parseInt(dredgerLocationId),
|
|
||||||
reclamationLocationId: parseInt(reclamationLocationId)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ exists: !!existingReport });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking for duplicate report:", error);
|
|
||||||
return json({ error: "Failed to check for duplicate" }, { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
|
||||||
import { json } from "@remix-run/node";
|
|
||||||
import { requireAuthLevel } from "~/utils/auth.server";
|
|
||||||
import { prisma } from "~/utils/db.server";
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
||||||
await requireAuthLevel(request, 2); // Only supervisors and admins
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const category = url.searchParams.get('category');
|
|
||||||
const model = url.searchParams.get('model');
|
|
||||||
const number = url.searchParams.get('number');
|
|
||||||
const dateFrom = url.searchParams.get('dateFrom');
|
|
||||||
const dateTo = url.searchParams.get('dateTo');
|
|
||||||
|
|
||||||
if (!category || !model || !number) {
|
|
||||||
return json({ error: "Equipment details are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build where clause for reports
|
|
||||||
const whereClause: any = {};
|
|
||||||
|
|
||||||
// Add date filters if provided
|
|
||||||
if (dateFrom || dateTo) {
|
|
||||||
whereClause.createdDate = {};
|
|
||||||
if (dateFrom) {
|
|
||||||
whereClause.createdDate.gte = new Date(dateFrom + 'T00:00:00.000Z');
|
|
||||||
}
|
|
||||||
if (dateTo) {
|
|
||||||
whereClause.createdDate.lte = new Date(dateTo + 'T23:59:59.999Z');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all reports that match the date filter
|
|
||||||
const reports = await prisma.report.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
orderBy: { createdDate: 'desc' },
|
|
||||||
include: {
|
|
||||||
employee: { select: { name: true } },
|
|
||||||
area: { select: { name: true } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter reports that have this equipment in their timeSheet
|
|
||||||
// Equipment machine format in timeSheet: "Model (Number)"
|
|
||||||
const equipmentMachine = `${model} (${number})`;
|
|
||||||
const usage: any[] = [];
|
|
||||||
|
|
||||||
reports.forEach((report) => {
|
|
||||||
const timeSheet = report.timeSheet as any[];
|
|
||||||
if (Array.isArray(timeSheet) && timeSheet.length > 0) {
|
|
||||||
timeSheet.forEach((entry: any) => {
|
|
||||||
// Check if the machine matches (trim whitespace)
|
|
||||||
const entryMachine = (entry.machine || '').trim();
|
|
||||||
const searchMachine = equipmentMachine.trim();
|
|
||||||
|
|
||||||
if (entryMachine === searchMachine) {
|
|
||||||
usage.push({
|
|
||||||
createdDate: report.createdDate,
|
|
||||||
shift: report.shift,
|
|
||||||
area: report.area,
|
|
||||||
employee: report.employee,
|
|
||||||
totalHours: entry.total || '00:00',
|
|
||||||
reason: entry.reason || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ usage });
|
|
||||||
};
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
|
||||||
import { json } from "@remix-run/node";
|
|
||||||
import { prisma } from "~/utils/db.server";
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const areaId = url.searchParams.get("areaId");
|
|
||||||
const dredgerLocationId = url.searchParams.get("dredgerLocationId");
|
|
||||||
const reclamationLocationId = url.searchParams.get("reclamationLocationId");
|
|
||||||
|
|
||||||
if (!areaId || !dredgerLocationId || !reclamationLocationId) {
|
|
||||||
return json({ data: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the most recent report for this location combination
|
|
||||||
const lastReport = await prisma.report.findFirst({
|
|
||||||
where: {
|
|
||||||
areaId: parseInt(areaId),
|
|
||||||
dredgerLocationId: parseInt(dredgerLocationId),
|
|
||||||
reclamationLocationId: parseInt(reclamationLocationId),
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdDate: 'desc',
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
dredgerLineLength: true,
|
|
||||||
shoreConnection: true,
|
|
||||||
reclamationHeight: true,
|
|
||||||
pipelineLength: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!lastReport) {
|
|
||||||
return json({ data: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const reclamationHeight = lastReport.reclamationHeight as any;
|
|
||||||
const pipelineLength = lastReport.pipelineLength as any;
|
|
||||||
|
|
||||||
// Calculate carry-forward values
|
|
||||||
const carryForwardData = {
|
|
||||||
dredgerLineLength: lastReport.dredgerLineLength,
|
|
||||||
shoreConnection: lastReport.shoreConnection,
|
|
||||||
reclamationHeightBase: (reclamationHeight.base || 0) + (reclamationHeight.extra || 0),
|
|
||||||
pipelineMain: (pipelineLength.main || 0) + (pipelineLength.ext1 || 0),
|
|
||||||
pipelineReserve: (pipelineLength.reserve || 0) + (pipelineLength.ext2 || 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
return json({ data: carryForwardData });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching last report data:", error);
|
|
||||||
return json({ data: null });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
|
||||||
import { json } from "@remix-run/node";
|
|
||||||
import { requireAuthLevel } from "~/utils/auth.server";
|
|
||||||
import { prisma } from "~/utils/db.server";
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
||||||
await requireAuthLevel(request, 2); // Only supervisors and admins
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const workerId = url.searchParams.get('workerId');
|
|
||||||
const dateFrom = url.searchParams.get('dateFrom');
|
|
||||||
const dateTo = url.searchParams.get('dateTo');
|
|
||||||
|
|
||||||
if (!workerId) {
|
|
||||||
return json({ error: "Worker ID is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build where clause for reports
|
|
||||||
const whereClause: any = {
|
|
||||||
shiftWorkers: {
|
|
||||||
some: {
|
|
||||||
workerId: parseInt(workerId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add date filters if provided
|
|
||||||
if (dateFrom || dateTo) {
|
|
||||||
whereClause.createdDate = {};
|
|
||||||
if (dateFrom) {
|
|
||||||
whereClause.createdDate.gte = new Date(dateFrom + 'T00:00:00.000Z');
|
|
||||||
}
|
|
||||||
if (dateTo) {
|
|
||||||
whereClause.createdDate.lte = new Date(dateTo + 'T23:59:59.999Z');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch shifts where this worker was assigned
|
|
||||||
const shifts = await prisma.report.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
orderBy: { createdDate: 'desc' },
|
|
||||||
include: {
|
|
||||||
employee: { select: { name: true } },
|
|
||||||
area: { select: { name: true } },
|
|
||||||
dredgerLocation: { select: { name: true, class: true } },
|
|
||||||
reclamationLocation: { select: { name: true } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ shifts });
|
|
||||||
};
|
|
||||||
@ -51,7 +51,7 @@ export default function Dashboard() {
|
|||||||
Welcome back, {user.name}!
|
Welcome back, {user.name}!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm sm:text-base text-gray-600">
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
Here's what's happening with your allhaffer operations today.
|
Here's what's happening with your phosphat operations today.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -109,13 +109,6 @@ export default function Equipment() {
|
|||||||
const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null);
|
const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
const [showUsageModal, setShowUsageModal] = useState(false);
|
|
||||||
const [selectedEquipment, setSelectedEquipment] = useState<any>(null);
|
|
||||||
const [equipmentUsage, setEquipmentUsage] = useState<any[]>([]);
|
|
||||||
const [totalHours, setTotalHours] = useState({ hours: 0, minutes: 0 });
|
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
|
|
||||||
|
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === "submitting";
|
||||||
const isEditing = editingEquipment !== null;
|
const isEditing = editingEquipment !== null;
|
||||||
@ -146,63 +139,6 @@ export default function Equipment() {
|
|||||||
setEditingEquipment(null);
|
setEditingEquipment(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewUsage = (item: any) => {
|
|
||||||
setSelectedEquipment(item);
|
|
||||||
setShowUsageModal(true);
|
|
||||||
setDateFrom('');
|
|
||||||
setDateTo('');
|
|
||||||
setEquipmentUsage([]);
|
|
||||||
setTotalHours({ hours: 0, minutes: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseUsageModal = () => {
|
|
||||||
setShowUsageModal(false);
|
|
||||||
setSelectedEquipment(null);
|
|
||||||
setEquipmentUsage([]);
|
|
||||||
setDateFrom('');
|
|
||||||
setDateTo('');
|
|
||||||
setTotalHours({ hours: 0, minutes: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotalHours = (usage: any[]) => {
|
|
||||||
let totalMinutes = 0;
|
|
||||||
usage.forEach((entry: any) => {
|
|
||||||
const [hours, minutes] = entry.totalHours.split(':').map(Number);
|
|
||||||
totalMinutes += hours * 60 + minutes;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
hours: Math.floor(totalMinutes / 60),
|
|
||||||
minutes: totalMinutes % 60
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterUsage = async () => {
|
|
||||||
if (!selectedEquipment) return;
|
|
||||||
|
|
||||||
setIsLoadingUsage(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
category: selectedEquipment.category,
|
|
||||||
model: selectedEquipment.model,
|
|
||||||
number: selectedEquipment.number.toString()
|
|
||||||
});
|
|
||||||
if (dateFrom) params.append('dateFrom', dateFrom);
|
|
||||||
if (dateTo) params.append('dateTo', dateTo);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/equipment-usage?${params.toString()}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.usage) {
|
|
||||||
setEquipmentUsage(data.usage);
|
|
||||||
setTotalHours(calculateTotalHours(data.usage));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setToast({ message: "Failed to load equipment usage", type: "error" });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingUsage(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryBadge = (category: string) => {
|
const getCategoryBadge = (category: string) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
Dozer: "bg-yellow-100 text-yellow-800",
|
Dozer: "bg-yellow-100 text-yellow-800",
|
||||||
@ -293,12 +229,6 @@ export default function Equipment() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<button
|
|
||||||
onClick={() => handleViewUsage(item)}
|
|
||||||
className="text-teal-600 hover:text-teal-900 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
View Usage
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(item)}
|
onClick={() => handleEdit(item)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||||
@ -350,12 +280,6 @@ export default function Equipment() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<button
|
|
||||||
onClick={() => handleViewUsage(item)}
|
|
||||||
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
View Usage
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(item)}
|
onClick={() => handleEdit(item)}
|
||||||
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||||
@ -405,174 +329,6 @@ export default function Equipment() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Equipment Usage Modal */}
|
|
||||||
{showUsageModal && selectedEquipment && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-5xl shadow-lg rounded-md bg-white">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Equipment Usage - {selectedEquipment.model}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{selectedEquipment.category} #{selectedEquipment.number}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseUsageModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Filters */}
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="usageDateFrom" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
From Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="usageDateFrom"
|
|
||||||
value={dateFrom}
|
|
||||||
onChange={(e) => setDateFrom(e.target.value)}
|
|
||||||
max={new Date().toISOString().split('T')[0]}
|
|
||||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="usageDateTo" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
To Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="usageDateTo"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={(e) => setDateTo(e.target.value)}
|
|
||||||
max={new Date().toISOString().split('T')[0]}
|
|
||||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={handleFilterUsage}
|
|
||||||
disabled={isLoadingUsage}
|
|
||||||
className="w-full px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoadingUsage ? 'Loading...' : 'Filter Usage'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Total Hours Summary */}
|
|
||||||
{equipmentUsage.length > 0 && (
|
|
||||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg className="h-8 w-8 text-indigo-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-indigo-900">Total Working Hours</p>
|
|
||||||
<p className="text-xs text-indigo-700">Across {equipmentUsage.length} shift{equipmentUsage.length !== 1 ? 's' : ''}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-3xl font-bold text-indigo-900">
|
|
||||||
{totalHours.hours}h {totalHours.minutes}m
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-indigo-700">
|
|
||||||
({totalHours.hours * 60 + totalHours.minutes} minutes)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Usage List */}
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{equipmentUsage.length > 0 ? (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Shift
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Area
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Employee
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Working Hours
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Reason
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{equipmentUsage.map((entry: any, index: number) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{new Date(entry.createdDate).toLocaleDateString('en-GB')}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${entry.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
|
|
||||||
{entry.shift.charAt(0).toUpperCase() + entry.shift.slice(1)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{entry.area.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{entry.employee.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-indigo-600">
|
|
||||||
{entry.totalHours}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-500">
|
|
||||||
{entry.reason || '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
{isLoadingUsage ? 'Loading usage data...' : 'Click "Filter Usage" to view equipment usage history'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={handleCloseUsageModal}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form Modal */}
|
{/* Form Modal */}
|
||||||
<FormModal
|
<FormModal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
|
|||||||
@ -73,12 +73,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
employee: { select: { name: true } },
|
employee: { select: { name: true } },
|
||||||
area: { select: { name: true } },
|
area: { select: { name: true } },
|
||||||
dredgerLocation: { select: { name: true, class: true } },
|
dredgerLocation: { select: { name: true, class: true } },
|
||||||
reclamationLocation: { select: { name: true } },
|
reclamationLocation: { select: { name: true } }
|
||||||
shiftWorkers: {
|
|
||||||
include: {
|
|
||||||
worker: { select: { id: true, name: true, status: true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nightShift: {
|
nightShift: {
|
||||||
@ -86,12 +81,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
employee: { select: { name: true } },
|
employee: { select: { name: true } },
|
||||||
area: { select: { name: true } },
|
area: { select: { name: true } },
|
||||||
dredgerLocation: { select: { name: true, class: true } },
|
dredgerLocation: { select: { name: true, class: true } },
|
||||||
reclamationLocation: { select: { name: true } },
|
reclamationLocation: { select: { name: true } }
|
||||||
shiftWorkers: {
|
|
||||||
include: {
|
|
||||||
worker: { select: { id: true, name: true, status: true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,7 +107,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const [areas, dredgerLocations, employees] = await Promise.all([
|
const [areas, dredgerLocations, employees] = await Promise.all([
|
||||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.employee.findMany({
|
prisma.employee.findMany({
|
||||||
where: { status: 'active' },
|
where: { status: 'active' },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
@ -136,8 +126,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
nightReport: sheet.nightShift
|
nightReport: sheet.nightShift
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
user,
|
user,
|
||||||
sheets: transformedSheets,
|
sheets: transformedSheets,
|
||||||
areas,
|
areas,
|
||||||
dredgerLocations,
|
dredgerLocations,
|
||||||
@ -159,8 +149,6 @@ export default function ReportSheet() {
|
|||||||
const [showViewModal, setShowViewModal] = useState(false);
|
const [showViewModal, setShowViewModal] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [showWorkersModal, setShowWorkersModal] = useState(false);
|
|
||||||
const [selectedShiftWorkers, setSelectedShiftWorkers] = useState<{ shift: string; workers: any[]; sheet: any } | null>(null);
|
|
||||||
|
|
||||||
const handleView = (sheet: ReportSheet) => {
|
const handleView = (sheet: ReportSheet) => {
|
||||||
setViewingSheet(sheet);
|
setViewingSheet(sheet);
|
||||||
@ -172,21 +160,6 @@ export default function ReportSheet() {
|
|||||||
setViewingSheet(null);
|
setViewingSheet(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewWorkers = (sheet: any, shift: 'day' | 'night') => {
|
|
||||||
const shiftReport = shift === 'day' ? sheet.dayReport : sheet.nightReport;
|
|
||||||
setSelectedShiftWorkers({
|
|
||||||
shift,
|
|
||||||
workers: shiftReport?.shiftWorkers || [],
|
|
||||||
sheet
|
|
||||||
});
|
|
||||||
setShowWorkersModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseWorkersModal = () => {
|
|
||||||
setShowWorkersModal(false);
|
|
||||||
setSelectedShiftWorkers(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter functions
|
// Filter functions
|
||||||
const handleFilterChange = (filterName: string, value: string) => {
|
const handleFilterChange = (filterName: string, value: string) => {
|
||||||
const newSearchParams = new URLSearchParams(searchParams);
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
@ -395,121 +368,103 @@ export default function ReportSheet() {
|
|||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Area
|
Area
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Locations
|
Locations
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Available Shifts
|
Available Shifts
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Employees
|
Employees
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sheets.map((sheet) => (
|
||||||
|
<tr key={sheet.id} className="hover:bg-gray-50 transition-colors duration-150">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{new Date(sheet.date).toLocaleDateString('en-GB')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{sheet.area}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
<div>Dredger: {sheet.dredgerLocation}</div>
|
||||||
|
<div className="text-gray-500">Reclamation: {sheet.reclamationLocation}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{sheet.dayReport && (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('day')}`}>
|
||||||
|
{getShiftIcon('day')}
|
||||||
|
<span className="ml-1">Day</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sheet.nightReport && (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('night')}`}>
|
||||||
|
{getShiftIcon('night')}
|
||||||
|
<span className="ml-1">Night</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${sheet.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{sheet.status === 'completed' ? (
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{sheet.dayReport && (
|
||||||
|
<div>Day: {sheet.dayReport.employee.name}</div>
|
||||||
|
)}
|
||||||
|
{sheet.nightReport && (
|
||||||
|
<div>Night: {sheet.nightReport.employee.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleView(sheet)}
|
||||||
|
className="text-blue-600 hover:text-blue-900 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
View Sheet
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
</tbody>
|
||||||
{sheets.map((sheet) => (
|
</table>
|
||||||
<tr key={sheet.id} className="hover:bg-gray-50 transition-colors duration-150">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{new Date(sheet.date).toLocaleDateString('en-GB')}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{sheet.area}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
<div>Dredger: {sheet.dredgerLocation}</div>
|
|
||||||
<div className="text-gray-500">Reclamation: {sheet.reclamationLocation}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{sheet.dayReport && (
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('day')}`}>
|
|
||||||
{getShiftIcon('day')}
|
|
||||||
<span className="ml-1">Day</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{sheet.nightReport && (
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('night')}`}>
|
|
||||||
{getShiftIcon('night')}
|
|
||||||
<span className="ml-1">Night</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${sheet.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
|
||||||
}`}>
|
|
||||||
{sheet.status === 'completed' ? (
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900 space-y-1">
|
|
||||||
{sheet.dayReport && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Day: {sheet.dayReport.employee.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(sheet, 'day')}
|
|
||||||
className="ml-2 text-xs text-teal-600 hover:text-teal-900"
|
|
||||||
title="View day shift workers"
|
|
||||||
>
|
|
||||||
({sheet.dayReport.shiftWorkers?.length || 0} workers)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sheet.nightReport && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Night: {sheet.nightReport.employee.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(sheet, 'night')}
|
|
||||||
className="ml-2 text-xs text-teal-600 hover:text-teal-900"
|
|
||||||
title="View night shift workers"
|
|
||||||
>
|
|
||||||
({sheet.nightReport.shiftWorkers?.length || 0} workers)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => handleView(sheet)}
|
|
||||||
className="text-blue-600 hover:text-blue-900 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
View Sheet
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -541,7 +496,7 @@ export default function ReportSheet() {
|
|||||||
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-xs font-medium text-gray-500">Dredger:</span>
|
<span className="text-xs font-medium text-gray-500">Dredger:</span>
|
||||||
@ -584,30 +539,12 @@ export default function ReportSheet() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<button
|
||||||
<button
|
onClick={() => handleView(sheet)}
|
||||||
onClick={() => handleView(sheet)}
|
className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150"
|
||||||
className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150"
|
>
|
||||||
>
|
View Sheet Details
|
||||||
View Sheet Details
|
</button>
|
||||||
</button>
|
|
||||||
{sheet.dayReport && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(sheet, 'day')}
|
|
||||||
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Day Workers ({sheet.dayReport.shiftWorkers?.length || 0})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sheet.nightReport && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(sheet, 'night')}
|
|
||||||
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Night Workers ({sheet.nightReport.shiftWorkers?.length || 0})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -630,116 +567,6 @@ export default function ReportSheet() {
|
|||||||
onClose={handleCloseViewModal}
|
onClose={handleCloseViewModal}
|
||||||
sheet={viewingSheet}
|
sheet={viewingSheet}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Workers Modal */}
|
|
||||||
{showWorkersModal && selectedShiftWorkers && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
{selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)} Shift Workers
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseWorkersModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Shift:</span>
|
|
||||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getShiftBadge(selectedShiftWorkers.shift)}`}>
|
|
||||||
{selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Date:</span>
|
|
||||||
<span className="ml-2 text-gray-900">
|
|
||||||
{new Date(selectedShiftWorkers.sheet.date).toLocaleDateString('en-GB')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Area:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{selectedShiftWorkers.sheet.area}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Employee:</span>
|
|
||||||
<span className="ml-2 text-gray-900">
|
|
||||||
{selectedShiftWorkers.shift === 'day'
|
|
||||||
? selectedShiftWorkers.sheet.dayReport?.employee.name
|
|
||||||
: selectedShiftWorkers.sheet.nightReport?.employee.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Assigned Workers ({selectedShiftWorkers.workers.length})
|
|
||||||
</h4>
|
|
||||||
{selectedShiftWorkers.workers.length > 0 ? (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
#
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Worker Name
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{selectedShiftWorkers.workers.map((sw: any, index: number) => (
|
|
||||||
<tr key={sw.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{sw.worker.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${sw.worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{sw.worker.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">No workers assigned to this shift</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleCloseWorkersModal}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -75,12 +75,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
employee: { select: { name: true } },
|
employee: { select: { name: true } },
|
||||||
area: { select: { name: true } },
|
area: { select: { name: true } },
|
||||||
dredgerLocation: { select: { name: true, class: true } },
|
dredgerLocation: { select: { name: true, class: true } },
|
||||||
reclamationLocation: { select: { name: true } },
|
reclamationLocation: { select: { name: true } }
|
||||||
shiftWorkers: {
|
|
||||||
include: {
|
|
||||||
worker: { select: { id: true, name: true, status: true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -636,12 +631,36 @@ export default function Reports() {
|
|||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [editingReport, setEditingReport] = useState<any>(null);
|
||||||
const [viewingReport, setViewingReport] = useState<any>(null);
|
const [viewingReport, setViewingReport] = useState<any>(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showViewModal, setShowViewModal] = useState(false);
|
const [showViewModal, setShowViewModal] = useState(false);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
const [showWorkersModal, setShowWorkersModal] = useState(false);
|
|
||||||
const [selectedReportWorkers, setSelectedReportWorkers] = useState<any>(null);
|
// Dynamic arrays state for editing only
|
||||||
|
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
|
||||||
|
id: string,
|
||||||
|
machine: string,
|
||||||
|
from1: string,
|
||||||
|
to1: string,
|
||||||
|
from2: string,
|
||||||
|
to2: string,
|
||||||
|
total: string,
|
||||||
|
reason: string
|
||||||
|
}>>([]);
|
||||||
|
const [stoppageEntries, setStoppageEntries] = useState<Array<{
|
||||||
|
id: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
total: string,
|
||||||
|
reason: string,
|
||||||
|
responsible: string,
|
||||||
|
note: string
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
const isEditing = editingReport !== null;
|
||||||
|
|
||||||
// Handle success/error messages from URL params and action data
|
// Handle success/error messages from URL params and action data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -658,6 +677,8 @@ export default function Reports() {
|
|||||||
window.history.replaceState({}, '', '/reports');
|
window.history.replaceState({}, '', '/reports');
|
||||||
} else if (actionData?.success) {
|
} else if (actionData?.success) {
|
||||||
setToast({ message: actionData.success, type: "success" });
|
setToast({ message: actionData.success, type: "success" });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingReport(null);
|
||||||
} else if (actionData?.errors?.form) {
|
} else if (actionData?.errors?.form) {
|
||||||
setToast({ message: actionData.errors.form, type: "error" });
|
setToast({ message: actionData.errors.form, type: "error" });
|
||||||
}
|
}
|
||||||
@ -668,19 +689,160 @@ export default function Reports() {
|
|||||||
setShowViewModal(true);
|
setShowViewModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (report: any) => {
|
||||||
|
setEditingReport(report);
|
||||||
|
// Load existing timesheet and stoppages data
|
||||||
|
setTimeSheetEntries(Array.isArray(report.timeSheet) ? report.timeSheet : []);
|
||||||
|
setStoppageEntries(Array.isArray(report.stoppages) ? report.stoppages : []);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove handleAdd since we're using a separate page
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingReport(null);
|
||||||
|
setTimeSheetEntries([]);
|
||||||
|
setStoppageEntries([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCloseViewModal = () => {
|
const handleCloseViewModal = () => {
|
||||||
setShowViewModal(false);
|
setShowViewModal(false);
|
||||||
setViewingReport(null);
|
setViewingReport(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewWorkers = (report: any) => {
|
// Helper function to calculate time difference in hours:minutes format
|
||||||
setSelectedReportWorkers(report);
|
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => {
|
||||||
setShowWorkersModal(true);
|
if (!from1 || !to1) return "00:00";
|
||||||
|
|
||||||
|
const parseTime = (timeStr: string) => {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
|
||||||
|
// First period
|
||||||
|
if (from1 && to1) {
|
||||||
|
const start1 = parseTime(from1);
|
||||||
|
let end1 = parseTime(to1);
|
||||||
|
if (end1 < start1)
|
||||||
|
end1 += 24 * 60;
|
||||||
|
totalMinutes += end1 - start1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second period
|
||||||
|
if (from2 && to2) {
|
||||||
|
const start2 = parseTime(from2);
|
||||||
|
let end2 = parseTime(to2);
|
||||||
|
if (end2 < start2)
|
||||||
|
end2 += 24 * 60;
|
||||||
|
totalMinutes += end2 - start2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatTime(Math.max(0, totalMinutes));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseWorkersModal = () => {
|
|
||||||
setShowWorkersModal(false);
|
|
||||||
setSelectedReportWorkers(null);
|
const calculateStoppageTime = (from: string, to: string) => {
|
||||||
|
if (!from || !to) return "00:00";
|
||||||
|
|
||||||
|
const parseTime = (timeStr: string) => {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMinutes = parseTime(from);
|
||||||
|
let endMinutes = parseTime(to);
|
||||||
|
if (endMinutes < startMinutes)
|
||||||
|
endMinutes += 24 * 60;
|
||||||
|
|
||||||
|
const totalMinutes = Math.max(0, endMinutes - startMinutes);
|
||||||
|
|
||||||
|
return formatTime(totalMinutes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TimeSheet management functions
|
||||||
|
const addTimeSheetEntry = () => {
|
||||||
|
const newEntry = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
machine: '',
|
||||||
|
from1: '',
|
||||||
|
to1: '',
|
||||||
|
from2: '',
|
||||||
|
to2: '',
|
||||||
|
total: '00:00',
|
||||||
|
reason: ''
|
||||||
|
};
|
||||||
|
setTimeSheetEntries([...timeSheetEntries, newEntry]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTimeSheetEntry = (id: string) => {
|
||||||
|
setTimeSheetEntries(timeSheetEntries.filter(entry => entry.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTimeSheetEntry = (id: string, field: string, value: string) => {
|
||||||
|
setTimeSheetEntries(timeSheetEntries.map(entry => {
|
||||||
|
if (entry.id === id) {
|
||||||
|
const updatedEntry = { ...entry, [field]: value };
|
||||||
|
// Auto-calculate total when time fields change
|
||||||
|
if (['from1', 'to1', 'from2', 'to2'].includes(field)) {
|
||||||
|
updatedEntry.total = calculateTimeDifference(
|
||||||
|
updatedEntry.from1,
|
||||||
|
updatedEntry.to1,
|
||||||
|
updatedEntry.from2,
|
||||||
|
updatedEntry.to2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updatedEntry;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stoppage management functions
|
||||||
|
const addStoppageEntry = () => {
|
||||||
|
const newEntry = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
total: '00:00',
|
||||||
|
reason: '',
|
||||||
|
responsible: '',
|
||||||
|
note: ''
|
||||||
|
};
|
||||||
|
setStoppageEntries([...stoppageEntries, newEntry]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStoppageEntry = (id: string) => {
|
||||||
|
setStoppageEntries(stoppageEntries.filter(entry => entry.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStoppageEntry = (id: string, field: string, value: string) => {
|
||||||
|
setStoppageEntries(stoppageEntries.map(entry => {
|
||||||
|
if (entry.id === id) {
|
||||||
|
const updatedEntry = { ...entry, [field]: value };
|
||||||
|
// Auto-calculate total when time fields change
|
||||||
|
if (['from', 'to'].includes(field)) {
|
||||||
|
updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to);
|
||||||
|
}
|
||||||
|
return updatedEntry;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getShiftBadge = (shift: string) => {
|
const getShiftBadge = (shift: string) => {
|
||||||
@ -835,7 +997,7 @@ export default function Reports() {
|
|||||||
{/* Total Reports */}
|
{/* Total Reports */}
|
||||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-gray-900">{stats.totalReports}</div>
|
<div className="text-2xl font-bold text-gray-900">{stats.totalReports}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Total Reports</div>
|
<div className="text-xs text-gray-600 mt-1">Total Shifts</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day Shift Count */}
|
{/* Day Shift Count */}
|
||||||
@ -1178,13 +1340,6 @@ export default function Reports() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(report)}
|
|
||||||
className="text-teal-600 hover:text-teal-900 transition-colors duration-150"
|
|
||||||
title="View workers"
|
|
||||||
>
|
|
||||||
Workers ({report.shiftWorkers?.length || 0})
|
|
||||||
</button>
|
|
||||||
{canDuplicateReport(report) ? (
|
{canDuplicateReport(report) ? (
|
||||||
<Form method="post" className="inline">
|
<Form method="post" className="inline">
|
||||||
<input type="hidden" name="intent" value="duplicate" />
|
<input type="hidden" name="intent" value="duplicate" />
|
||||||
@ -1231,12 +1386,12 @@ export default function Reports() {
|
|||||||
)}
|
)}
|
||||||
{canEditReport(report) && (
|
{canEditReport(report) && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<button
|
||||||
to={`/reports/${report.id}/edit`}
|
onClick={() => handleEdit(report)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</button>
|
||||||
<Form method="post" className="inline">
|
<Form method="post" className="inline">
|
||||||
<input type="hidden" name="intent" value="delete" />
|
<input type="hidden" name="intent" value="delete" />
|
||||||
<input type="hidden" name="id" value={report.id} />
|
<input type="hidden" name="id" value={report.id} />
|
||||||
@ -1313,12 +1468,6 @@ export default function Reports() {
|
|||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkers(report)}
|
|
||||||
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
View Workers ({report.shiftWorkers?.length || 0})
|
|
||||||
</button>
|
|
||||||
{canDuplicateReport(report) ? (
|
{canDuplicateReport(report) ? (
|
||||||
<Form method="post" className="w-full">
|
<Form method="post" className="w-full">
|
||||||
<input type="hidden" name="intent" value="duplicate" />
|
<input type="hidden" name="intent" value="duplicate" />
|
||||||
@ -1364,12 +1513,12 @@ export default function Reports() {
|
|||||||
)}
|
)}
|
||||||
{canEditReport(report) && (
|
{canEditReport(report) && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Link
|
<button
|
||||||
to={`/reports/${report.id}/edit`}
|
onClick={() => handleEdit(report)}
|
||||||
className="flex-1 text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
className="flex-1 text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</button>
|
||||||
<Form method="post" className="flex-1">
|
<Form method="post" className="flex-1">
|
||||||
<input type="hidden" name="intent" value="delete" />
|
<input type="hidden" name="intent" value="delete" />
|
||||||
<input type="hidden" name="id" value={report.id} />
|
<input type="hidden" name="id" value={report.id} />
|
||||||
@ -1414,6 +1563,31 @@ export default function Reports() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form Modal - Only for editing existing reports */}
|
||||||
|
{isEditing && (
|
||||||
|
<ReportFormModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
editingReport={editingReport}
|
||||||
|
actionData={actionData}
|
||||||
|
areas={areas}
|
||||||
|
dredgerLocations={dredgerLocations}
|
||||||
|
reclamationLocations={reclamationLocations}
|
||||||
|
foremen={foremen}
|
||||||
|
equipment={equipment}
|
||||||
|
timeSheetEntries={timeSheetEntries}
|
||||||
|
stoppageEntries={stoppageEntries}
|
||||||
|
addTimeSheetEntry={addTimeSheetEntry}
|
||||||
|
removeTimeSheetEntry={removeTimeSheetEntry}
|
||||||
|
updateTimeSheetEntry={updateTimeSheetEntry}
|
||||||
|
addStoppageEntry={addStoppageEntry}
|
||||||
|
removeStoppageEntry={removeStoppageEntry}
|
||||||
|
updateStoppageEntry={updateStoppageEntry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* View Modal */}
|
{/* View Modal */}
|
||||||
<ReportViewModal
|
<ReportViewModal
|
||||||
isOpen={showViewModal}
|
isOpen={showViewModal}
|
||||||
@ -1421,112 +1595,6 @@ export default function Reports() {
|
|||||||
report={viewingReport}
|
report={viewingReport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Workers Modal */}
|
|
||||||
{showWorkersModal && selectedReportWorkers && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Workers - Shift #{selectedReportWorkers.id}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseWorkersModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Shift:</span>
|
|
||||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getShiftBadge(selectedReportWorkers.shift)}`}>
|
|
||||||
{selectedReportWorkers.shift.charAt(0).toUpperCase() + selectedReportWorkers.shift.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Date:</span>
|
|
||||||
<span className="ml-2 text-gray-900">
|
|
||||||
{new Date(selectedReportWorkers.createdDate).toLocaleDateString('en-GB')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Area:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{selectedReportWorkers.area.name}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Employee:</span>
|
|
||||||
<span className="ml-2 text-gray-900">{selectedReportWorkers.employee.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Assigned Workers ({selectedReportWorkers.shiftWorkers?.length || 0})
|
|
||||||
</h4>
|
|
||||||
{selectedReportWorkers.shiftWorkers && selectedReportWorkers.shiftWorkers.length > 0 ? (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
#
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Worker Name
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{selectedReportWorkers.shiftWorkers.map((sw: any, index: number) => (
|
|
||||||
<tr key={sw.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{sw.worker.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${sw.worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{sw.worker.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">No workers assigned to this shift</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleCloseWorkersModal}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -13,13 +13,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const user = await requireAuthLevel(request, 1); // All employees can create reports
|
const user = await requireAuthLevel(request, 1); // All employees can create reports
|
||||||
|
|
||||||
// Get dropdown data for form
|
// Get dropdown data for form
|
||||||
const [areas, dredgerLocations, reclamationLocations, foremen, equipment, workers] = await Promise.all([
|
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([
|
||||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
|
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
|
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
|
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] })
|
||||||
prisma.worker.findMany({ where: { status: 'active' }, orderBy: { name: 'asc' } })
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
@ -28,8 +27,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
dredgerLocations,
|
dredgerLocations,
|
||||||
reclamationLocations,
|
reclamationLocations,
|
||||||
foremen,
|
foremen,
|
||||||
equipment,
|
equipment
|
||||||
workers
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -41,7 +39,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.log("Form data received:", Object.fromEntries(formData.entries()));
|
console.log("Form data received:", Object.fromEntries(formData.entries()));
|
||||||
|
|
||||||
const createdDate = formData.get("createdDate");
|
|
||||||
const shift = formData.get("shift");
|
const shift = formData.get("shift");
|
||||||
const areaId = formData.get("areaId");
|
const areaId = formData.get("areaId");
|
||||||
const dredgerLocationId = formData.get("dredgerLocationId");
|
const dredgerLocationId = formData.get("dredgerLocationId");
|
||||||
@ -62,16 +59,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const statsLoaders = formData.get("statsLoaders");
|
const statsLoaders = formData.get("statsLoaders");
|
||||||
const statsForeman = formData.get("statsForeman");
|
const statsForeman = formData.get("statsForeman");
|
||||||
const statsLaborer = formData.get("statsLaborer");
|
const statsLaborer = formData.get("statsLaborer");
|
||||||
const workersListData = formData.get("workersList");
|
|
||||||
const timeSheetData = formData.get("timeSheetData");
|
const timeSheetData = formData.get("timeSheetData");
|
||||||
const stoppagesData = formData.get("stoppagesData");
|
const stoppagesData = formData.get("stoppagesData");
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
// console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection });
|
// console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection });
|
||||||
|
|
||||||
if (typeof createdDate !== "string" || !createdDate) {
|
|
||||||
return json({ errors: { createdDate: "Report date is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
|
if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
|
||||||
console.log("Shift validation failed:", shift);
|
console.log("Shift validation failed:", shift);
|
||||||
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
|
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
|
||||||
@ -96,7 +89,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Parse JSON arrays
|
// Parse JSON arrays
|
||||||
let timeSheet = [];
|
let timeSheet = [];
|
||||||
let stoppages = [];
|
let stoppages = [];
|
||||||
let workersList = [];
|
|
||||||
|
|
||||||
if (timeSheetData && typeof timeSheetData === "string") {
|
if (timeSheetData && typeof timeSheetData === "string") {
|
||||||
try {
|
try {
|
||||||
@ -114,14 +106,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workersListData && typeof workersListData === "string") {
|
|
||||||
try {
|
|
||||||
workersList = JSON.parse(workersListData);
|
|
||||||
} catch (e) {
|
|
||||||
workersList = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build automatic notes for pipeline extensions
|
// Build automatic notes for pipeline extensions
|
||||||
const ext1Value = parseInt(pipelineExt1 as string) || 0;
|
const ext1Value = parseInt(pipelineExt1 as string) || 0;
|
||||||
const ext2Value = parseInt(pipelineExt2 as string) || 0;
|
const ext2Value = parseInt(pipelineExt2 as string) || 0;
|
||||||
@ -144,19 +128,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
if (automaticNotes.length > 0) {
|
if (automaticNotes.length > 0) {
|
||||||
const automaticNotesText = automaticNotes.join(', ');
|
const automaticNotesText = automaticNotes.join(', ');
|
||||||
if (finalNotes.trim()) {
|
if (finalNotes.trim()) {
|
||||||
finalNotes = `${automaticNotesText}.\n${finalNotes}`;
|
finalNotes = `${automaticNotesText}. ${finalNotes}`;
|
||||||
} else {
|
} else {
|
||||||
finalNotes = automaticNotesText;
|
finalNotes = automaticNotesText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the selected date
|
|
||||||
const reportDate = new Date(createdDate);
|
|
||||||
|
|
||||||
const report = await prisma.report.create({
|
const report = await prisma.report.create({
|
||||||
data: {
|
data: {
|
||||||
employeeId: user.id,
|
employeeId: user.id,
|
||||||
createdDate: reportDate,
|
|
||||||
shift,
|
shift,
|
||||||
areaId: parseInt(areaId),
|
areaId: parseInt(areaId),
|
||||||
dredgerLocationId: parseInt(dredgerLocationId),
|
dredgerLocationId: parseInt(dredgerLocationId),
|
||||||
@ -186,16 +166,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create ShiftWorker records for each selected worker
|
|
||||||
if (workersList.length > 0) {
|
|
||||||
await prisma.shiftWorker.createMany({
|
|
||||||
data: workersList.map((workerId: number) => ({
|
|
||||||
reportId: report.id,
|
|
||||||
workerId: workerId
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manage sheet creation/update
|
// Manage sheet creation/update
|
||||||
await manageSheet(
|
await manageSheet(
|
||||||
report.id,
|
report.id,
|
||||||
@ -214,13 +184,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function NewReport() {
|
export default function NewReport() {
|
||||||
const { user, areas, dredgerLocations, reclamationLocations, foremen, equipment, workers } = useLoaderData<typeof loader>();
|
const { user, areas, dredgerLocations, reclamationLocations, foremen, equipment } = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
// Form state to preserve values across steps
|
// Form state to preserve values across steps
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
createdDate: new Date().toISOString().split('T')[0], // Default to today
|
|
||||||
shift: '',
|
shift: '',
|
||||||
areaId: '',
|
areaId: '',
|
||||||
dredgerLocationId: '',
|
dredgerLocationId: '',
|
||||||
@ -241,12 +210,6 @@ export default function NewReport() {
|
|||||||
notes: ''
|
notes: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isLaborerManuallyEdited, setIsLaborerManuallyEdited] = useState(false);
|
|
||||||
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
|
||||||
const [selectedWorkers, setSelectedWorkers] = useState<number[]>([]);
|
|
||||||
const [workerSearchTerm, setWorkerSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// Dynamic arrays state
|
// Dynamic arrays state
|
||||||
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
|
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
|
||||||
id: string,
|
id: string,
|
||||||
@ -412,58 +375,6 @@ export default function NewReport() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch last report data when location combination is complete (in step 2)
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchLastReportData = async () => {
|
|
||||||
if (currentStep === 2 && formData.areaId && formData.dredgerLocationId && formData.reclamationLocationId) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/last-report-data?areaId=${formData.areaId}&dredgerLocationId=${formData.dredgerLocationId}&reclamationLocationId=${formData.reclamationLocationId}`
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.data) {
|
|
||||||
// Only update fields that are still at their default values
|
|
||||||
setFormData(prev => {
|
|
||||||
const updates: any = {};
|
|
||||||
|
|
||||||
// Update dredger line length if still empty or default
|
|
||||||
if (!prev.dredgerLineLength || prev.dredgerLineLength === '') {
|
|
||||||
updates.dredgerLineLength = data.data.dredgerLineLength.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update shore connection if still empty or default
|
|
||||||
if (!prev.shoreConnection || prev.shoreConnection === '') {
|
|
||||||
updates.shoreConnection = data.data.shoreConnection.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update reclamation height base if still at default
|
|
||||||
if (prev.reclamationHeightBase === '0') {
|
|
||||||
updates.reclamationHeightBase = data.data.reclamationHeightBase.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update pipeline main if still at default
|
|
||||||
if (prev.pipelineMain === '0') {
|
|
||||||
updates.pipelineMain = data.data.pipelineMain.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update pipeline reserve if still at default
|
|
||||||
if (prev.pipelineReserve === '0') {
|
|
||||||
updates.pipelineReserve = data.data.pipelineReserve.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(updates).length > 0 ? { ...prev, ...updates } : prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching last report data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchLastReportData();
|
|
||||||
}, [currentStep, formData.areaId, formData.dredgerLocationId, formData.reclamationLocationId]);
|
|
||||||
|
|
||||||
// Auto-calculate equipment counts based on time sheet entries
|
// Auto-calculate equipment counts based on time sheet entries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const counts = { dozers: 0, excavators: 0, loaders: 0 };
|
const counts = { dozers: 0, excavators: 0, loaders: 0 };
|
||||||
@ -533,42 +444,12 @@ export default function NewReport() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = async (event?: React.MouseEvent<HTMLButtonElement>) => {
|
const nextStep = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate report on step 1
|
|
||||||
if (currentStep === 1) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/check-duplicate-report', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
createdDate: formData.createdDate,
|
|
||||||
shift: formData.shift,
|
|
||||||
areaId: formData.areaId,
|
|
||||||
dredgerLocationId: formData.dredgerLocationId,
|
|
||||||
reclamationLocationId: formData.reclamationLocationId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.exists) {
|
|
||||||
setValidationError(`A report already exists for ${formData.shift} shift in this area, dredger location, and reclamation location on ${new Date(formData.createdDate).toLocaleDateString()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationError(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for duplicate:', error);
|
|
||||||
setValidationError('Failed to validate report. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're on step 3 (Equipment & Time Sheet) and have zero equipment
|
// Check if we're on step 3 (Equipment & Time Sheet) and have zero equipment
|
||||||
if (currentStep === 3) {
|
if (currentStep === 3) {
|
||||||
const totalEquipment = parseInt(formData.statsDozers) + parseInt(formData.statsExc) + parseInt(formData.statsLoaders);
|
const totalEquipment = parseInt(formData.statsDozers) + parseInt(formData.statsExc) + parseInt(formData.statsLoaders);
|
||||||
@ -614,16 +495,15 @@ export default function NewReport() {
|
|||||||
|
|
||||||
// Validation functions for each step
|
// Validation functions for each step
|
||||||
const isStep1Valid = () => {
|
const isStep1Valid = () => {
|
||||||
return formData.createdDate &&
|
return formData.shift &&
|
||||||
formData.shift &&
|
|
||||||
formData.areaId &&
|
formData.areaId &&
|
||||||
formData.dredgerLocationId &&
|
formData.dredgerLocationId &&
|
||||||
formData.reclamationLocationId;
|
formData.dredgerLineLength &&
|
||||||
|
!isNaN(parseInt(formData.dredgerLineLength));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isStep2Valid = () => {
|
const isStep2Valid = () => {
|
||||||
return formData.dredgerLineLength &&
|
return formData.reclamationLocationId &&
|
||||||
!isNaN(parseInt(formData.dredgerLineLength)) &&
|
|
||||||
formData.shoreConnection &&
|
formData.shoreConnection &&
|
||||||
!isNaN(parseInt(formData.shoreConnection));
|
!isNaN(parseInt(formData.shoreConnection));
|
||||||
};
|
};
|
||||||
@ -643,30 +523,6 @@ export default function NewReport() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-update laborer count when workers change (only if not manually edited)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLaborerManuallyEdited) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
statsLaborer: selectedWorkers.length.toString()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [selectedWorkers, isLaborerManuallyEdited]);
|
|
||||||
|
|
||||||
// Worker selection functions
|
|
||||||
const toggleWorker = (workerId: number) => {
|
|
||||||
setSelectedWorkers(prev =>
|
|
||||||
prev.includes(workerId)
|
|
||||||
? prev.filter(id => id !== workerId)
|
|
||||||
: [...prev, workerId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredWorkers = workers.filter(worker =>
|
|
||||||
worker.name.toLowerCase().includes(workerSearchTerm.toLowerCase()) &&
|
|
||||||
!selectedWorkers.includes(worker.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="max-w-full mx-auto">
|
<div className="max-w-full mx-auto">
|
||||||
@ -725,36 +581,7 @@ export default function NewReport() {
|
|||||||
{/* Step 1: Basic Information */}
|
{/* Step 1: Basic Information */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
{validationError && (
|
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
|
||||||
<div className="flex">
|
|
||||||
<svg className="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-red-800">{validationError}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<div>
|
|
||||||
<label htmlFor="createdDate" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Report Date <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="createdDate"
|
|
||||||
name="createdDate"
|
|
||||||
required
|
|
||||||
value={formData.createdDate}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateFormData('createdDate', e.target.value);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Shift <span className="text-red-500">*</span>
|
Shift <span className="text-red-500">*</span>
|
||||||
@ -825,47 +652,6 @@ export default function NewReport() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Reclamation Location <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="reclamationLocationId"
|
|
||||||
name="reclamationLocationId"
|
|
||||||
required
|
|
||||||
value={formData.reclamationLocationId}
|
|
||||||
onChange={(e) => updateFormData('reclamationLocationId', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Select reclamation location</option>
|
|
||||||
{reclamationLocations.map((location) => (
|
|
||||||
<option key={location.id} value={location.id}>{location.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{actionData?.errors?.reclamationLocationId && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Step 2: Location & Pipeline Details */}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
|
||||||
{/* Info message about carry-forward */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<svg className="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
Values below are automatically filled from the last report for this location combination. You can modify them as needed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Dredger Line Length (m) <span className="text-red-500">*</span>
|
Dredger Line Length (m) <span className="text-red-500">*</span>
|
||||||
@ -885,6 +671,27 @@ export default function NewReport() {
|
|||||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Step 2: Location & Pipeline Details */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Reclamation Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select id="reclamationLocationId" name="reclamationLocationId" required value={formData.reclamationLocationId} onChange={(e) => updateFormData('reclamationLocationId', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<option value="">Select reclamation location</option>
|
||||||
|
{reclamationLocations.map((location) => (
|
||||||
|
<option key={location.id} value={location.id}>{location.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{actionData?.errors?.reclamationLocationId && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">Shore Connection <span className="text-red-500">*</span></label>
|
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">Shore Connection <span className="text-red-500">*</span></label>
|
||||||
<input type="number" id="shoreConnection" name="shoreConnection" required min="0" value={formData.shoreConnection} onChange={(e) => updateFormData('shoreConnection', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter connection length" />
|
<input type="number" id="shoreConnection" name="shoreConnection" required min="0" value={formData.shoreConnection} onChange={(e) => updateFormData('shoreConnection', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter connection length" />
|
||||||
@ -921,65 +728,7 @@ export default function NewReport() {
|
|||||||
<div><label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">Excavators <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsExc" name="statsExc" min="0" value={formData.statsExc} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
|
<div><label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">Excavators <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsExc" name="statsExc" min="0" value={formData.statsExc} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
|
||||||
<div><label htmlFor="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">Loaders <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
|
<div><label htmlFor="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">Loaders <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
|
||||||
<div><label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">Foreman</label><select id="statsForeman" name="statsForeman" value={formData.statsForeman} onChange={(e) => updateFormData('statsForeman', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select foreman</option>{foremen.map((foreman) => (<option key={foreman.id} value={foreman.name}>{foreman.name}</option>))}</select></div>
|
<div><label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">Foreman</label><select id="statsForeman" name="statsForeman" value={formData.statsForeman} onChange={(e) => updateFormData('statsForeman', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select foreman</option>{foremen.map((foreman) => (<option key={foreman.id} value={foreman.name}>{foreman.name}</option>))}</select></div>
|
||||||
<div><label htmlFor="statsLaborer" className="block text-sm font-medium text-gray-700 mb-2">Laborers <span className="text-xs text-gray-500">({selectedWorkers.length} selected)</span></label><input type="number" id="statsLaborer" name="statsLaborer" min="0" value={formData.statsLaborer} onChange={(e) => {
|
<div><label htmlFor="statsLaborer" className="block text-sm font-medium text-gray-700 mb-2">Laborers</label><input type="number" id="statsLaborer" name="statsLaborer" min="0" value={formData.statsLaborer} onChange={(e) => updateFormData('statsLaborer', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||||
updateFormData('statsLaborer', e.target.value);
|
|
||||||
setIsLaborerManuallyEdited(true);
|
|
||||||
}} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" title="Auto-calculated based on selected workers, but can be edited" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Select Workers</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search workers..."
|
|
||||||
value={workerSearchTerm}
|
|
||||||
onChange={(e) => setWorkerSearchTerm(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
{workerSearchTerm && filteredWorkers.length > 0 && (
|
|
||||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
|
||||||
{filteredWorkers.map((worker) => (
|
|
||||||
<button
|
|
||||||
key={worker.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
toggleWorker(worker.id);
|
|
||||||
setWorkerSearchTerm('');
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 hover:bg-gray-100 focus:outline-none focus:bg-gray-100"
|
|
||||||
>
|
|
||||||
{worker.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedWorkers.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedWorkers.map((workerId) => {
|
|
||||||
const worker = workers.find(w => w.id === workerId);
|
|
||||||
return worker ? (
|
|
||||||
<span
|
|
||||||
key={workerId}
|
|
||||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800"
|
|
||||||
>
|
|
||||||
{worker.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleWorker(workerId)}
|
|
||||||
className="ml-2 inline-flex items-center justify-center w-4 h-4 text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -1100,21 +849,19 @@ export default function NewReport() {
|
|||||||
{/* Hidden inputs for dynamic data */}
|
{/* Hidden inputs for dynamic data */}
|
||||||
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
|
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
|
||||||
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
|
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
|
||||||
<input type="hidden" name="workersList" value={JSON.stringify(selectedWorkers)} />
|
|
||||||
|
|
||||||
{/* Hidden inputs for form data from all steps */}
|
{/* Hidden inputs for form data from all steps */}
|
||||||
{currentStep !== 1 && (
|
{currentStep !== 1 && (
|
||||||
<>
|
<>
|
||||||
<input type="hidden" name="createdDate" value={formData.createdDate} />
|
|
||||||
<input type="hidden" name="shift" value={formData.shift} />
|
<input type="hidden" name="shift" value={formData.shift} />
|
||||||
<input type="hidden" name="areaId" value={formData.areaId} />
|
<input type="hidden" name="areaId" value={formData.areaId} />
|
||||||
<input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} />
|
<input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} />
|
||||||
<input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
|
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentStep !== 2 && (
|
{currentStep !== 2 && (
|
||||||
<>
|
<>
|
||||||
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} />
|
<input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
|
||||||
<input type="hidden" name="shoreConnection" value={formData.shoreConnection} />
|
<input type="hidden" name="shoreConnection" value={formData.shoreConnection} />
|
||||||
<input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} />
|
<input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} />
|
||||||
<input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} />
|
<input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} />
|
||||||
|
|||||||
@ -1,533 +0,0 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
|
||||||
import { json, redirect } from "@remix-run/node";
|
|
||||||
import { Form, useActionData, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
|
|
||||||
import { requireAuthLevel } from "~/utils/auth.server";
|
|
||||||
import DashboardLayout from "~/components/DashboardLayout";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { prisma } from "~/utils/db.server";
|
|
||||||
import Toast from "~/components/Toast";
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => [{ title: "Workers - Alhaffer Report System" }];
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
||||||
const user = await requireAuthLevel(request, 2); // Only supervisors and admins
|
|
||||||
|
|
||||||
const workers = await prisma.worker.findMany({
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ user, workers });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
||||||
await requireAuthLevel(request, 2);
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
const intent = formData.get("intent");
|
|
||||||
|
|
||||||
if (intent === "create") {
|
|
||||||
const name = formData.get("name");
|
|
||||||
|
|
||||||
if (typeof name !== "string" || !name) {
|
|
||||||
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.worker.create({
|
|
||||||
data: { name, status: "active" }
|
|
||||||
});
|
|
||||||
return redirect("/workers?success=Worker created successfully!");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === "P2002") {
|
|
||||||
return json({ errors: { name: "A worker with this name already exists" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
return json({ errors: { form: "Failed to create worker" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "update") {
|
|
||||||
const id = formData.get("id");
|
|
||||||
const name = formData.get("name");
|
|
||||||
const status = formData.get("status");
|
|
||||||
|
|
||||||
if (typeof id !== "string" || !id) {
|
|
||||||
return json({ errors: { form: "Invalid worker ID" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof name !== "string" || !name) {
|
|
||||||
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof status !== "string" || !["active", "inactive"].includes(status)) {
|
|
||||||
return json({ errors: { status: "Valid status is required" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.worker.update({
|
|
||||||
where: { id: parseInt(id) },
|
|
||||||
data: { name, status }
|
|
||||||
});
|
|
||||||
return redirect("/workers?success=Worker updated successfully!");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === "P2002") {
|
|
||||||
return json({ errors: { name: "A worker with this name already exists" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
return json({ errors: { form: "Failed to update worker" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "delete") {
|
|
||||||
const id = formData.get("id");
|
|
||||||
|
|
||||||
if (typeof id !== "string" || !id) {
|
|
||||||
return json({ errors: { form: "Invalid worker ID" } }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.worker.delete({
|
|
||||||
where: { id: parseInt(id) }
|
|
||||||
});
|
|
||||||
return redirect("/workers?success=Worker deleted successfully!");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === "P2003") {
|
|
||||||
return redirect("/workers?error=Cannot delete worker: worker is assigned to shifts");
|
|
||||||
}
|
|
||||||
return redirect("/workers?error=Failed to delete worker");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ errors: { form: "Invalid action" } }, { status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Workers() {
|
|
||||||
const { user, workers } = useLoaderData<typeof loader>();
|
|
||||||
const actionData = useActionData<typeof action>();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [editingWorker, setEditingWorker] = useState<any>(null);
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | null>(null);
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
||||||
const [showShiftsModal, setShowShiftsModal] = useState(false);
|
|
||||||
const [selectedWorker, setSelectedWorker] = useState<any>(null);
|
|
||||||
const [workerShifts, setWorkerShifts] = useState<any[]>([]);
|
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
const [isLoadingShifts, setIsLoadingShifts] = useState(false);
|
|
||||||
|
|
||||||
const isSubmitting = navigation.state === "submitting";
|
|
||||||
|
|
||||||
// Handle success messages from URL params
|
|
||||||
useEffect(() => {
|
|
||||||
const successMessage = searchParams.get("success");
|
|
||||||
const errorMessage = searchParams.get("error");
|
|
||||||
|
|
||||||
if (successMessage) {
|
|
||||||
setToast({ message: successMessage, type: "success" });
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
// Close modals on success
|
|
||||||
setShowModal(false);
|
|
||||||
setEditingWorker(null);
|
|
||||||
setShowDeleteConfirm(null);
|
|
||||||
} else if (errorMessage) {
|
|
||||||
setToast({ message: errorMessage, type: "error" });
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
// Close delete confirm modal on error
|
|
||||||
setShowDeleteConfirm(null);
|
|
||||||
}
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
// Handle action errors
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionData?.errors) {
|
|
||||||
const errors = actionData.errors as any;
|
|
||||||
const errorMessage = errors.form || errors.name || errors.status || "An error occurred";
|
|
||||||
setToast({ message: errorMessage, type: "error" });
|
|
||||||
}
|
|
||||||
}, [actionData]);
|
|
||||||
|
|
||||||
const handleEdit = (worker: any) => {
|
|
||||||
setEditingWorker(worker);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setShowModal(false);
|
|
||||||
setEditingWorker(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewShifts = (worker: any) => {
|
|
||||||
setSelectedWorker(worker);
|
|
||||||
setShowShiftsModal(true);
|
|
||||||
setDateFrom('');
|
|
||||||
setDateTo('');
|
|
||||||
setWorkerShifts([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseShiftsModal = () => {
|
|
||||||
setShowShiftsModal(false);
|
|
||||||
setSelectedWorker(null);
|
|
||||||
setWorkerShifts([]);
|
|
||||||
setDateFrom('');
|
|
||||||
setDateTo('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterShifts = async () => {
|
|
||||||
if (!selectedWorker) return;
|
|
||||||
|
|
||||||
setIsLoadingShifts(true);
|
|
||||||
try {
|
|
||||||
// Build query parameters
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
workerId: selectedWorker.id.toString()
|
|
||||||
});
|
|
||||||
if (dateFrom) params.append('dateFrom', dateFrom);
|
|
||||||
if (dateTo) params.append('dateTo', dateTo);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/worker-shifts?${params.toString()}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.shifts) {
|
|
||||||
setWorkerShifts(data.shifts);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setToast({ message: "Failed to load shifts", type: "error" });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingShifts(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardLayout user={user}>
|
|
||||||
{toast && (
|
|
||||||
<Toast
|
|
||||||
message={toast.message}
|
|
||||||
type={toast.type}
|
|
||||||
onClose={() => setToast(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-6 sm:mb-8">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Workers Management</h1>
|
|
||||||
<p className="mt-2 text-sm sm:text-base text-gray-600">Manage laborers and workers</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
Add Worker
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white shadow-md rounded-lg overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<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">Status</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{workers.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={3} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
No workers found. Click "Add Worker" to create one.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
workers.map((worker) => (
|
|
||||||
<tr key={worker.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{worker.name}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{worker.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewShifts(worker)}
|
|
||||||
className="text-teal-600 hover:text-teal-900"
|
|
||||||
>
|
|
||||||
View Shifts
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(worker)}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(worker.id)}
|
|
||||||
className="text-red-600 hover:text-red-900"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
|
||||||
{showModal && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
{editingWorker ? "Edit Worker" : "Add New Worker"}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form method="post" className="space-y-4">
|
|
||||||
<input type="hidden" name="intent" value={editingWorker ? "update" : "create"} />
|
|
||||||
{editingWorker && <input type="hidden" name="id" value={editingWorker.id} />}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Worker Name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
defaultValue={editingWorker?.name}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
placeholder="Enter worker name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingWorker && (
|
|
||||||
<div>
|
|
||||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Status <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
name="status"
|
|
||||||
required
|
|
||||||
defaultValue={editingWorker?.status}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Saving..." : editingWorker ? "Update" : "Create"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View Shifts Modal */}
|
|
||||||
{showShiftsModal && selectedWorker && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Shifts for {selectedWorker.name}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseShiftsModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Filters */}
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="dateFrom" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
From Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFrom"
|
|
||||||
value={dateFrom}
|
|
||||||
onChange={(e) => setDateFrom(e.target.value)}
|
|
||||||
max={new Date().toISOString().split('T')[0]}
|
|
||||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="dateTo" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
To Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateTo"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={(e) => setDateTo(e.target.value)}
|
|
||||||
max={new Date().toISOString().split('T')[0]}
|
|
||||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={handleFilterShifts}
|
|
||||||
disabled={isLoadingShifts}
|
|
||||||
className="w-full px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoadingShifts ? 'Loading...' : 'Filter Shifts'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shifts List */}
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{workerShifts.length > 0 ? (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Shift
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Area
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Dredger Location
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Employee
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{workerShifts.map((shift: any) => (
|
|
||||||
<tr key={shift.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{new Date(shift.createdDate).toLocaleDateString('en-GB')}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${shift.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
|
|
||||||
{shift.shift.charAt(0).toUpperCase() + shift.shift.slice(1)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{shift.area.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{shift.dredgerLocation.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{shift.employee.name}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
{isLoadingShifts ? 'Loading shifts...' : 'Click "Filter Shifts" to view shifts for this worker'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{workerShifts.length > 0 && `Showing ${workerShifts.length} shift${workerShifts.length !== 1 ? 's' : ''}`}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseShiftsModal}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{showDeleteConfirm && (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
|
||||||
<div className="mt-3 text-center">
|
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
|
||||||
<svg className="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mt-4">Delete Worker</h3>
|
|
||||||
<div className="mt-2 px-7 py-3">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Are you sure you want to delete this worker? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(null)}
|
|
||||||
className="flex-1 px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<Form method="post" className="flex-1">
|
|
||||||
<input type="hidden" name="intent" value="delete" />
|
|
||||||
<input type="hidden" name="id" value={showDeleteConfirm} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full px-4 py-2 bg-red-600 text-white text-base font-medium rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-300"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
89
package-lock.json
generated
89
package-lock.json
generated
@ -1517,11 +1517,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/dev": {
|
"node_modules/@remix-run/dev": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.16.8.tgz",
|
||||||
"integrity": "sha512-Ou9iIewCs4IIoC5FjYBsfNzcCfdrc+3V8thRjULVMvTDfFxRoL+uNz/AlD3jC7Vm8Q08Iryy0joCOh8oghIhvQ==",
|
"integrity": "sha512-2EKByaD5CDwh7H56UFVCqc90kCZ9LukPlSwkcsR3gj7WlfL7sXtcIqIopcToAlKAeao3HDbhBlBT2CTOivxZCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.21.8",
|
"@babel/core": "^7.21.8",
|
||||||
"@babel/generator": "^7.21.5",
|
"@babel/generator": "^7.21.5",
|
||||||
@ -1533,9 +1532,9 @@
|
|||||||
"@babel/types": "^7.22.5",
|
"@babel/types": "^7.22.5",
|
||||||
"@mdx-js/mdx": "^2.3.0",
|
"@mdx-js/mdx": "^2.3.0",
|
||||||
"@npmcli/package-json": "^4.0.1",
|
"@npmcli/package-json": "^4.0.1",
|
||||||
"@remix-run/node": "2.17.1",
|
"@remix-run/node": "2.16.8",
|
||||||
"@remix-run/router": "1.23.0",
|
"@remix-run/router": "1.23.0",
|
||||||
"@remix-run/server-runtime": "2.17.1",
|
"@remix-run/server-runtime": "2.16.8",
|
||||||
"@types/mdx": "^2.0.5",
|
"@types/mdx": "^2.0.5",
|
||||||
"@vanilla-extract/integration": "^6.2.0",
|
"@vanilla-extract/integration": "^6.2.0",
|
||||||
"arg": "^5.0.1",
|
"arg": "^5.0.1",
|
||||||
@ -1587,8 +1586,8 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@remix-run/react": "^2.17.0",
|
"@remix-run/react": "^2.16.8",
|
||||||
"@remix-run/serve": "^2.17.0",
|
"@remix-run/serve": "^2.16.8",
|
||||||
"typescript": "^5.1.0",
|
"typescript": "^5.1.0",
|
||||||
"vite": "^5.1.0 || ^6.0.0",
|
"vite": "^5.1.0 || ^6.0.0",
|
||||||
"wrangler": "^3.28.2"
|
"wrangler": "^3.28.2"
|
||||||
@ -1609,12 +1608,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/express": {
|
"node_modules/@remix-run/express": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.16.8.tgz",
|
||||||
"integrity": "sha512-qsjfpj2rUwF5jN0XmECpPSgPKWAXVzM4rV1mLgomIrjJISHfzxfNYd9m2/qhyueOZY07tcaUK0LXkjAEvrdMpA==",
|
"integrity": "sha512-NNTosiAJ4jZCRDfWSjV+3Fyu7KoHPeEHruLZEPRNDuXO6Nm5EkRvIkMwdfwyJ+ajE5IPotu8MFtPyNtm3sw/gw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/node": "2.17.1"
|
"@remix-run/node": "2.16.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@ -1630,12 +1628,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/node": {
|
"node_modules/@remix-run/node": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.16.8.tgz",
|
||||||
"integrity": "sha512-pHmHTuLE1Lwazulx3gjrHobgBCsa+Xiq8WUO0ruLeDfEw2DU0c0SNSiyNkugu3rIZautroBwRaOoy7CWJL9xhQ==",
|
"integrity": "sha512-foeYXU3mdaBJZnbtGbM8mNdHowz2+QnVGDRo7P3zgFkmsccMEflArGZNbkACGKd9xwDguTxxMJ6cuXBC4jIfgQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/server-runtime": "2.17.1",
|
"@remix-run/server-runtime": "2.16.8",
|
||||||
"@remix-run/web-fetch": "^4.4.2",
|
"@remix-run/web-fetch": "^4.4.2",
|
||||||
"@web3-storage/multipart-parser": "^1.0.0",
|
"@web3-storage/multipart-parser": "^1.0.0",
|
||||||
"cookie-signature": "^1.1.0",
|
"cookie-signature": "^1.1.0",
|
||||||
@ -1656,13 +1653,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/react": {
|
"node_modules/@remix-run/react": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.16.8.tgz",
|
||||||
"integrity": "sha512-5MqRK2Z5gkQMDqGfjXSACf/HzvOA+5ug9kiSqaPpK9NX0OF4NlS+cAPKXQWuzc2iLSp6r1RGu8FU1jpZbhsaug==",
|
"integrity": "sha512-JmoBUnEu/nPLkU6NGNIG7rfLM97gPpr1LYRJeV680hChr0/2UpfQQwcRLtHz03w1Gz1i/xONAAVOvRHVcXkRlA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.0",
|
"@remix-run/router": "1.23.0",
|
||||||
"@remix-run/server-runtime": "2.17.1",
|
"@remix-run/server-runtime": "2.16.8",
|
||||||
"react-router": "6.30.0",
|
"react-router": "6.30.0",
|
||||||
"react-router-dom": "6.30.0",
|
"react-router-dom": "6.30.0",
|
||||||
"turbo-stream": "2.4.1"
|
"turbo-stream": "2.4.1"
|
||||||
@ -1690,18 +1686,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/serve": {
|
"node_modules/@remix-run/serve": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.16.8.tgz",
|
||||||
"integrity": "sha512-7ep8k31c7z7sNoQRhPBRF4wsSxdbZ7FE11Hi8bQjcW6hK/rQnuHM+cGMv8w9qGjzsYilZeukaHHp0XNtxS4DEQ==",
|
"integrity": "sha512-4exyeXCZoc/Vo8Zc+6Eyao3ONwOyNOK3Yeb0LLkWXd4aeFQ4v59i5fq/j/E+68UnpD/UZQl1Bj0k2hQnGQZhlQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/express": "2.17.1",
|
"@remix-run/express": "2.16.8",
|
||||||
"@remix-run/node": "2.17.1",
|
"@remix-run/node": "2.16.8",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.7.4",
|
||||||
"express": "^4.20.0",
|
"express": "^4.20.0",
|
||||||
"get-port": "5.1.1",
|
"get-port": "5.1.1",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.0",
|
||||||
"source-map-support": "^0.5.21"
|
"source-map-support": "^0.5.21"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -1712,10 +1707,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/server-runtime": {
|
"node_modules/@remix-run/server-runtime": {
|
||||||
"version": "2.17.1",
|
"version": "2.16.8",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.16.8.tgz",
|
||||||
"integrity": "sha512-d1Vp9FxX4KafB111vP2E5C1fmWzPI+gHZ674L1drq+N8Bp9U6FBspi7GAZSU5K5Kxa4T6UF+aE1gK6pVi9R8sw==",
|
"integrity": "sha512-ZwWOam4GAQTx10t+wK09YuYctd2Koz5Xy/klDgUN3lmTXmwbV0tZU0baiXEqZXrvyD+WDZ4b0ADDW9Df3+dpzA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.0",
|
"@remix-run/router": "1.23.0",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
@ -2114,8 +2108,7 @@
|
|||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
@ -3130,11 +3123,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vanilla-extract/integration/node_modules/vite": {
|
"node_modules/@vanilla-extract/integration/node_modules/vite": {
|
||||||
"version": "5.4.20",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@ -4488,7 +4480,6 @@
|
|||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@ -11915,10 +11906,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
|
||||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
@ -13107,11 +13097,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.6",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@ -1,23 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Worker" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ShiftWorker" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"reportId" INTEGER NOT NULL,
|
|
||||||
"workerId" INTEGER NOT NULL,
|
|
||||||
CONSTRAINT "ShiftWorker_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "ShiftWorker_workerId_fkey" FOREIGN KEY ("workerId") REFERENCES "Worker" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Worker_name_key" ON "Worker"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "ShiftWorker_reportId_workerId_key" ON "ShiftWorker"("reportId", "workerId");
|
|
||||||
@ -31,13 +31,10 @@ model Report {
|
|||||||
timeSheet Json // JSON: Array of timesheet objects
|
timeSheet Json // JSON: Array of timesheet objects
|
||||||
stoppages Json // JSON: Array of stoppage records
|
stoppages Json // JSON: Array of stoppage records
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
// Sheet relations
|
// Sheet relations
|
||||||
daySheetFor Sheet[] @relation("DayShift")
|
daySheetFor Sheet[] @relation("DayShift")
|
||||||
nightSheetFor Sheet[] @relation("NightShift")
|
nightSheetFor Sheet[] @relation("NightShift")
|
||||||
|
|
||||||
// Worker relations
|
|
||||||
shiftWorkers ShiftWorker[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Area {
|
model Area {
|
||||||
@ -96,44 +93,25 @@ model Equipment {
|
|||||||
number Int
|
number Int
|
||||||
}
|
}
|
||||||
|
|
||||||
model Worker {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String @unique
|
|
||||||
status String @default("active") // 'active' or 'inactive'
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
shiftWorkers ShiftWorker[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model ShiftWorker {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
reportId Int
|
|
||||||
workerId Int
|
|
||||||
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
|
||||||
worker Worker @relation(fields: [workerId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([reportId, workerId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Sheet {
|
model Sheet {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
dayShiftId Int?
|
dayShiftId Int?
|
||||||
nightShiftId Int?
|
nightShiftId Int?
|
||||||
status String @default("pending") // 'pending', 'completed'
|
status String @default("pending") // 'pending', 'completed'
|
||||||
areaId Int
|
areaId Int
|
||||||
dredgerLocationId Int
|
dredgerLocationId Int
|
||||||
reclamationLocationId Int
|
reclamationLocationId Int
|
||||||
date String // Store as string in YYYY-MM-DD format
|
date String // Store as string in YYYY-MM-DD format
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
area Area @relation(fields: [areaId], references: [id])
|
area Area @relation(fields: [areaId], references: [id])
|
||||||
dredgerLocation DredgerLocation @relation(fields: [dredgerLocationId], references: [id])
|
dredgerLocation DredgerLocation @relation(fields: [dredgerLocationId], references: [id])
|
||||||
reclamationLocation ReclamationLocation @relation(fields: [reclamationLocationId], references: [id])
|
reclamationLocation ReclamationLocation @relation(fields: [reclamationLocationId], references: [id])
|
||||||
dayShift Report? @relation("DayShift", fields: [dayShiftId], references: [id])
|
dayShift Report? @relation("DayShift", fields: [dayShiftId], references: [id])
|
||||||
nightShift Report? @relation("NightShift", fields: [nightShiftId], references: [id])
|
nightShift Report? @relation("NightShift", fields: [nightShiftId], references: [id])
|
||||||
|
|
||||||
@@unique([areaId, dredgerLocationId, reclamationLocationId, date])
|
@@unique([areaId, dredgerLocationId, reclamationLocationId, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,35 +140,6 @@ async function main() {
|
|||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
// Seed Workers
|
|
||||||
const workers = await Promise.all([
|
|
||||||
prisma.worker.upsert({
|
|
||||||
where: { name: 'Ahmed Ali' },
|
|
||||||
update: {},
|
|
||||||
create: { name: 'Ahmed Ali', status: 'active' }
|
|
||||||
}),
|
|
||||||
prisma.worker.upsert({
|
|
||||||
where: { name: 'Mohammed Hassan' },
|
|
||||||
update: {},
|
|
||||||
create: { name: 'Mohammed Hassan', status: 'active' }
|
|
||||||
}),
|
|
||||||
prisma.worker.upsert({
|
|
||||||
where: { name: 'Omar Ibrahim' },
|
|
||||||
update: {},
|
|
||||||
create: { name: 'Omar Ibrahim', status: 'active' }
|
|
||||||
}),
|
|
||||||
prisma.worker.upsert({
|
|
||||||
where: { name: 'Khalid Mahmoud' },
|
|
||||||
update: {},
|
|
||||||
create: { name: 'Khalid Mahmoud', status: 'active' }
|
|
||||||
}),
|
|
||||||
prisma.worker.upsert({
|
|
||||||
where: { name: 'Youssef Saleh' },
|
|
||||||
update: {},
|
|
||||||
create: { name: 'Youssef Saleh', status: 'active' }
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
console.log('✅ Database seeded successfully!')
|
console.log('✅ Database seeded successfully!')
|
||||||
console.log(`Created ${areas.length} areas`)
|
console.log(`Created ${areas.length} areas`)
|
||||||
console.log(`Created ${dredgerLocations.length} dredger locations`)
|
console.log(`Created ${dredgerLocations.length} dredger locations`)
|
||||||
@ -176,7 +147,6 @@ async function main() {
|
|||||||
console.log(`Created 1 employee`)
|
console.log(`Created 1 employee`)
|
||||||
console.log(`Created 1 foreman`)
|
console.log(`Created 1 foreman`)
|
||||||
console.log(`Created ${equipment.length} equipment records`)
|
console.log(`Created ${equipment.length} equipment records`)
|
||||||
console.log(`Created ${workers.length} workers`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user