Compare commits
8 Commits
v2.0-featu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a79ff3e729 | |||
| 367a4c9734 | |||
| cb0960299d | |||
| b1472a7f72 | |||
| 377863b595 | |||
| e3987c2fe6 | |||
| 812e668e17 | |||
| 3fbc0a2093 |
@ -49,9 +49,6 @@ coverage
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Explicitly include start.sh
|
||||
!start.sh
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
30
.env.dokploy
Normal file
30
.env.dokploy
Normal file
@ -0,0 +1,30 @@
|
||||
# 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
|
||||
39
.env.production
Normal file
39
.env.production
Normal file
@ -0,0 +1,39 @@
|
||||
# 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"
|
||||
65
CARRY_FORWARD_IMPLEMENTATION.md
Normal file
65
CARRY_FORWARD_IMPLEMENTATION.md
Normal file
@ -0,0 +1,65 @@
|
||||
# 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
|
||||
@ -1,147 +0,0 @@
|
||||
# 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.
|
||||
70
EDIT_REPORT_IMPLEMENTATION.md
Normal file
70
EDIT_REPORT_IMPLEMENTATION.md
Normal file
@ -0,0 +1,70 @@
|
||||
# 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)
|
||||
405
EDIT_ROUTE_COMPLETE_GUIDE.md
Normal file
405
EDIT_ROUTE_COMPLETE_GUIDE.md
Normal file
@ -0,0 +1,405 @@
|
||||
# ✅ 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,6 +312,17 @@ function SidebarContent({
|
||||
Foreman
|
||||
</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
|
||||
to="/employees"
|
||||
icon={
|
||||
|
||||
@ -694,7 +694,9 @@ function ReportSheetNotes({ report }: { report: any }) {
|
||||
</div>
|
||||
<div className="border-2 border-black mb-4 min-h-[100px]">
|
||||
<div className="p-4 text-center">
|
||||
<pre>
|
||||
{report.notes || 'No additional notes'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -474,8 +474,10 @@ function ReportNotes({ report }: { report: any }) {
|
||||
Notes & Comments
|
||||
</div>
|
||||
<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'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -53,4 +53,4 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/routes/api.check-duplicate-report.ts
Normal file
42
app/routes/api.check-duplicate-report.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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 });
|
||||
}
|
||||
};
|
||||
72
app/routes/api.equipment-usage.ts
Normal file
72
app/routes/api.equipment-usage.ts
Normal file
@ -0,0 +1,72 @@
|
||||
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 });
|
||||
};
|
||||
55
app/routes/api.last-report-data.ts
Normal file
55
app/routes/api.last-report-data.ts
Normal file
@ -0,0 +1,55 @@
|
||||
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 });
|
||||
}
|
||||
};
|
||||
51
app/routes/api.worker-shifts.ts
Normal file
51
app/routes/api.worker-shifts.ts
Normal file
@ -0,0 +1,51 @@
|
||||
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}!
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Here's what's happening with your phosphat operations today.
|
||||
Here's what's happening with your allhaffer operations today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -109,6 +109,13 @@ export default function Equipment() {
|
||||
const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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 isEditing = editingEquipment !== null;
|
||||
@ -139,6 +146,63 @@ export default function Equipment() {
|
||||
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 colors = {
|
||||
Dozer: "bg-yellow-100 text-yellow-800",
|
||||
@ -229,6 +293,12 @@ export default function Equipment() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<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
|
||||
onClick={() => handleEdit(item)}
|
||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||
@ -280,6 +350,12 @@ export default function Equipment() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
@ -329,6 +405,174 @@ export default function Equipment() {
|
||||
)}
|
||||
</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 */}
|
||||
<FormModal
|
||||
isOpen={showModal}
|
||||
|
||||
@ -73,7 +73,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
employee: { select: { name: true } },
|
||||
area: { select: { name: 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: {
|
||||
@ -81,7 +86,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
employee: { select: { name: true } },
|
||||
area: { select: { name: 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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,7 +117,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const [areas, dredgerLocations, employees] = await Promise.all([
|
||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.employee.findMany({
|
||||
prisma.employee.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
@ -126,8 +136,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
nightReport: sheet.nightShift
|
||||
}));
|
||||
|
||||
return json({
|
||||
user,
|
||||
return json({
|
||||
user,
|
||||
sheets: transformedSheets,
|
||||
areas,
|
||||
dredgerLocations,
|
||||
@ -149,6 +159,8 @@ export default function ReportSheet() {
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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) => {
|
||||
setViewingSheet(sheet);
|
||||
@ -160,6 +172,21 @@ export default function ReportSheet() {
|
||||
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
|
||||
const handleFilterChange = (filterName: string, value: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
@ -368,103 +395,121 @@ export default function ReportSheet() {
|
||||
<div className="hidden lg:block">
|
||||
<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">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Area
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Locations
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Available Shifts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Employees
|
||||
</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">
|
||||
{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>
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Area
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Locations
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Available Shifts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Employees
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 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>
|
||||
|
||||
@ -496,7 +541,7 @@ export default function ReportSheet() {
|
||||
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Dredger:</span>
|
||||
@ -539,12 +584,30 @@ export default function ReportSheet() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
View Sheet Details
|
||||
</button>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
View Sheet Details
|
||||
</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>
|
||||
@ -567,6 +630,116 @@ export default function ReportSheet() {
|
||||
onClose={handleCloseViewModal}
|
||||
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>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@ -75,7 +75,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
employee: { select: { name: true } },
|
||||
area: { select: { name: 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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -631,36 +636,12 @@ export default function Reports() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [editingReport, setEditingReport] = useState<any>(null);
|
||||
const [viewingReport, setViewingReport] = useState<any>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(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;
|
||||
const [showWorkersModal, setShowWorkersModal] = useState(false);
|
||||
const [selectedReportWorkers, setSelectedReportWorkers] = useState<any>(null);
|
||||
|
||||
// Handle success/error messages from URL params and action data
|
||||
useEffect(() => {
|
||||
@ -677,8 +658,6 @@ export default function Reports() {
|
||||
window.history.replaceState({}, '', '/reports');
|
||||
} else if (actionData?.success) {
|
||||
setToast({ message: actionData.success, type: "success" });
|
||||
setShowModal(false);
|
||||
setEditingReport(null);
|
||||
} else if (actionData?.errors?.form) {
|
||||
setToast({ message: actionData.errors.form, type: "error" });
|
||||
}
|
||||
@ -689,160 +668,19 @@ export default function Reports() {
|
||||
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 = () => {
|
||||
setShowViewModal(false);
|
||||
setViewingReport(null);
|
||||
};
|
||||
|
||||
// Helper function to calculate time difference in hours:minutes format
|
||||
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => {
|
||||
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 handleViewWorkers = (report: any) => {
|
||||
setSelectedReportWorkers(report);
|
||||
setShowWorkersModal(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
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 handleCloseWorkersModal = () => {
|
||||
setShowWorkersModal(false);
|
||||
setSelectedReportWorkers(null);
|
||||
};
|
||||
|
||||
const getShiftBadge = (shift: string) => {
|
||||
@ -997,7 +835,7 @@ export default function Reports() {
|
||||
{/* Total Reports */}
|
||||
<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-xs text-gray-600 mt-1">Total Shifts</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Total Reports</div>
|
||||
</div>
|
||||
|
||||
{/* Day Shift Count */}
|
||||
@ -1340,6 +1178,13 @@ export default function Reports() {
|
||||
>
|
||||
View
|
||||
</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) ? (
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="duplicate" />
|
||||
@ -1386,12 +1231,12 @@ export default function Reports() {
|
||||
)}
|
||||
{canEditReport(report) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(report)}
|
||||
<Link
|
||||
to={`/reports/${report.id}/edit`}
|
||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Link>
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
@ -1468,6 +1313,12 @@ export default function Reports() {
|
||||
>
|
||||
View Details
|
||||
</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) ? (
|
||||
<Form method="post" className="w-full">
|
||||
<input type="hidden" name="intent" value="duplicate" />
|
||||
@ -1513,12 +1364,12 @@ export default function Reports() {
|
||||
)}
|
||||
{canEditReport(report) && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(report)}
|
||||
<Link
|
||||
to={`/reports/${report.id}/edit`}
|
||||
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
|
||||
</button>
|
||||
</Link>
|
||||
<Form method="post" className="flex-1">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
@ -1563,31 +1414,6 @@ export default function Reports() {
|
||||
)}
|
||||
</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 */}
|
||||
<ReportViewModal
|
||||
isOpen={showViewModal}
|
||||
@ -1595,6 +1421,112 @@ export default function Reports() {
|
||||
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 && (
|
||||
<Toast
|
||||
|
||||
1197
app/routes/reports_.$id.edit.tsx
Normal file
1197
app/routes/reports_.$id.edit.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,12 +13,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireAuthLevel(request, 1); // All employees can create reports
|
||||
|
||||
// Get dropdown data for form
|
||||
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([
|
||||
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.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
|
||||
prisma.worker.findMany({ where: { status: 'active' }, orderBy: { name: 'asc' } })
|
||||
]);
|
||||
|
||||
return json({
|
||||
@ -27,7 +28,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
dredgerLocations,
|
||||
reclamationLocations,
|
||||
foremen,
|
||||
equipment
|
||||
equipment,
|
||||
workers
|
||||
});
|
||||
};
|
||||
|
||||
@ -39,6 +41,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Debug logging
|
||||
console.log("Form data received:", Object.fromEntries(formData.entries()));
|
||||
|
||||
const createdDate = formData.get("createdDate");
|
||||
const shift = formData.get("shift");
|
||||
const areaId = formData.get("areaId");
|
||||
const dredgerLocationId = formData.get("dredgerLocationId");
|
||||
@ -59,12 +62,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
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");
|
||||
|
||||
// Validation
|
||||
// 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)) {
|
||||
console.log("Shift validation failed:", shift);
|
||||
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
|
||||
@ -89,6 +96,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Parse JSON arrays
|
||||
let timeSheet = [];
|
||||
let stoppages = [];
|
||||
let workersList = [];
|
||||
|
||||
if (timeSheetData && typeof timeSheetData === "string") {
|
||||
try {
|
||||
@ -106,6 +114,14 @@ 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
|
||||
const ext1Value = parseInt(pipelineExt1 as string) || 0;
|
||||
const ext2Value = parseInt(pipelineExt2 as string) || 0;
|
||||
@ -128,15 +144,19 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
if (automaticNotes.length > 0) {
|
||||
const automaticNotesText = automaticNotes.join(', ');
|
||||
if (finalNotes.trim()) {
|
||||
finalNotes = `${automaticNotesText}. ${finalNotes}`;
|
||||
finalNotes = `${automaticNotesText}.\n${finalNotes}`;
|
||||
} else {
|
||||
finalNotes = automaticNotesText;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the selected date
|
||||
const reportDate = new Date(createdDate);
|
||||
|
||||
const report = await prisma.report.create({
|
||||
data: {
|
||||
employeeId: user.id,
|
||||
createdDate: reportDate,
|
||||
shift,
|
||||
areaId: parseInt(areaId),
|
||||
dredgerLocationId: parseInt(dredgerLocationId),
|
||||
@ -166,6 +186,16 @@ 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
|
||||
await manageSheet(
|
||||
report.id,
|
||||
@ -184,12 +214,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function NewReport() {
|
||||
const { user, areas, dredgerLocations, reclamationLocations, foremen, equipment } = useLoaderData<typeof loader>();
|
||||
const { user, areas, dredgerLocations, reclamationLocations, foremen, equipment, workers } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Form state to preserve values across steps
|
||||
const [formData, setFormData] = useState({
|
||||
createdDate: new Date().toISOString().split('T')[0], // Default to today
|
||||
shift: '',
|
||||
areaId: '',
|
||||
dredgerLocationId: '',
|
||||
@ -210,6 +241,12 @@ export default function NewReport() {
|
||||
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
|
||||
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
|
||||
id: string,
|
||||
@ -375,6 +412,58 @@ 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
|
||||
useEffect(() => {
|
||||
const counts = { dozers: 0, excavators: 0, loaders: 0 };
|
||||
@ -444,12 +533,42 @@ export default function NewReport() {
|
||||
}));
|
||||
};
|
||||
|
||||
const nextStep = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const nextStep = async (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
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
|
||||
if (currentStep === 3) {
|
||||
const totalEquipment = parseInt(formData.statsDozers) + parseInt(formData.statsExc) + parseInt(formData.statsLoaders);
|
||||
@ -495,15 +614,16 @@ export default function NewReport() {
|
||||
|
||||
// Validation functions for each step
|
||||
const isStep1Valid = () => {
|
||||
return formData.shift &&
|
||||
return formData.createdDate &&
|
||||
formData.shift &&
|
||||
formData.areaId &&
|
||||
formData.dredgerLocationId &&
|
||||
formData.dredgerLineLength &&
|
||||
!isNaN(parseInt(formData.dredgerLineLength));
|
||||
formData.reclamationLocationId;
|
||||
};
|
||||
|
||||
const isStep2Valid = () => {
|
||||
return formData.reclamationLocationId &&
|
||||
return formData.dredgerLineLength &&
|
||||
!isNaN(parseInt(formData.dredgerLineLength)) &&
|
||||
formData.shoreConnection &&
|
||||
!isNaN(parseInt(formData.shoreConnection));
|
||||
};
|
||||
@ -523,6 +643,30 @@ 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 (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="max-w-full mx-auto">
|
||||
@ -581,7 +725,36 @@ export default function NewReport() {
|
||||
{/* Step 1: Basic Information */}
|
||||
{currentStep === 1 && (
|
||||
<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>
|
||||
<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>
|
||||
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shift <span className="text-red-500">*</span>
|
||||
@ -652,6 +825,47 @@ export default function NewReport() {
|
||||
)}
|
||||
</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>
|
||||
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dredger Line Length (m) <span className="text-red-500">*</span>
|
||||
@ -671,27 +885,6 @@ export default function NewReport() {
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
|
||||
)}
|
||||
</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>
|
||||
<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" />
|
||||
@ -728,7 +921,65 @@ 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="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="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>
|
||||
<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) => {
|
||||
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>
|
||||
@ -849,19 +1100,21 @@ export default function NewReport() {
|
||||
{/* Hidden inputs for dynamic data */}
|
||||
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
|
||||
<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 */}
|
||||
{currentStep !== 1 && (
|
||||
<>
|
||||
<input type="hidden" name="createdDate" value={formData.createdDate} />
|
||||
<input type="hidden" name="shift" value={formData.shift} />
|
||||
<input type="hidden" name="areaId" value={formData.areaId} />
|
||||
<input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} />
|
||||
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} />
|
||||
<input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
|
||||
</>
|
||||
)}
|
||||
{currentStep !== 2 && (
|
||||
<>
|
||||
<input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
|
||||
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} />
|
||||
<input type="hidden" name="shoreConnection" value={formData.shoreConnection} />
|
||||
<input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} />
|
||||
<input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} />
|
||||
|
||||
533
app/routes/workers.tsx
Normal file
533
app/routes/workers.tsx
Normal file
@ -0,0 +1,533 @@
|
||||
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,10 +1517,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/dev": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.16.8.tgz",
|
||||
"integrity": "sha512-2EKByaD5CDwh7H56UFVCqc90kCZ9LukPlSwkcsR3gj7WlfL7sXtcIqIopcToAlKAeao3HDbhBlBT2CTOivxZCg==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.17.1.tgz",
|
||||
"integrity": "sha512-Ou9iIewCs4IIoC5FjYBsfNzcCfdrc+3V8thRjULVMvTDfFxRoL+uNz/AlD3jC7Vm8Q08Iryy0joCOh8oghIhvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.8",
|
||||
"@babel/generator": "^7.21.5",
|
||||
@ -1532,9 +1533,9 @@
|
||||
"@babel/types": "^7.22.5",
|
||||
"@mdx-js/mdx": "^2.3.0",
|
||||
"@npmcli/package-json": "^4.0.1",
|
||||
"@remix-run/node": "2.16.8",
|
||||
"@remix-run/node": "2.17.1",
|
||||
"@remix-run/router": "1.23.0",
|
||||
"@remix-run/server-runtime": "2.16.8",
|
||||
"@remix-run/server-runtime": "2.17.1",
|
||||
"@types/mdx": "^2.0.5",
|
||||
"@vanilla-extract/integration": "^6.2.0",
|
||||
"arg": "^5.0.1",
|
||||
@ -1586,8 +1587,8 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@remix-run/react": "^2.16.8",
|
||||
"@remix-run/serve": "^2.16.8",
|
||||
"@remix-run/react": "^2.17.0",
|
||||
"@remix-run/serve": "^2.17.0",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.1.0 || ^6.0.0",
|
||||
"wrangler": "^3.28.2"
|
||||
@ -1608,11 +1609,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/express": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.16.8.tgz",
|
||||
"integrity": "sha512-NNTosiAJ4jZCRDfWSjV+3Fyu7KoHPeEHruLZEPRNDuXO6Nm5EkRvIkMwdfwyJ+ajE5IPotu8MFtPyNtm3sw/gw==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.17.1.tgz",
|
||||
"integrity": "sha512-qsjfpj2rUwF5jN0XmECpPSgPKWAXVzM4rV1mLgomIrjJISHfzxfNYd9m2/qhyueOZY07tcaUK0LXkjAEvrdMpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/node": "2.16.8"
|
||||
"@remix-run/node": "2.17.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@ -1628,11 +1630,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/node": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.16.8.tgz",
|
||||
"integrity": "sha512-foeYXU3mdaBJZnbtGbM8mNdHowz2+QnVGDRo7P3zgFkmsccMEflArGZNbkACGKd9xwDguTxxMJ6cuXBC4jIfgQ==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.17.1.tgz",
|
||||
"integrity": "sha512-pHmHTuLE1Lwazulx3gjrHobgBCsa+Xiq8WUO0ruLeDfEw2DU0c0SNSiyNkugu3rIZautroBwRaOoy7CWJL9xhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/server-runtime": "2.16.8",
|
||||
"@remix-run/server-runtime": "2.17.1",
|
||||
"@remix-run/web-fetch": "^4.4.2",
|
||||
"@web3-storage/multipart-parser": "^1.0.0",
|
||||
"cookie-signature": "^1.1.0",
|
||||
@ -1653,12 +1656,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/react": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.16.8.tgz",
|
||||
"integrity": "sha512-JmoBUnEu/nPLkU6NGNIG7rfLM97gPpr1LYRJeV680hChr0/2UpfQQwcRLtHz03w1Gz1i/xONAAVOvRHVcXkRlA==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.17.1.tgz",
|
||||
"integrity": "sha512-5MqRK2Z5gkQMDqGfjXSACf/HzvOA+5ug9kiSqaPpK9NX0OF4NlS+cAPKXQWuzc2iLSp6r1RGu8FU1jpZbhsaug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"@remix-run/server-runtime": "2.16.8",
|
||||
"@remix-run/server-runtime": "2.17.1",
|
||||
"react-router": "6.30.0",
|
||||
"react-router-dom": "6.30.0",
|
||||
"turbo-stream": "2.4.1"
|
||||
@ -1686,17 +1690,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/serve": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.16.8.tgz",
|
||||
"integrity": "sha512-4exyeXCZoc/Vo8Zc+6Eyao3ONwOyNOK3Yeb0LLkWXd4aeFQ4v59i5fq/j/E+68UnpD/UZQl1Bj0k2hQnGQZhlQ==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.17.1.tgz",
|
||||
"integrity": "sha512-7ep8k31c7z7sNoQRhPBRF4wsSxdbZ7FE11Hi8bQjcW6hK/rQnuHM+cGMv8w9qGjzsYilZeukaHHp0XNtxS4DEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/express": "2.16.8",
|
||||
"@remix-run/node": "2.16.8",
|
||||
"@remix-run/express": "2.17.1",
|
||||
"@remix-run/node": "2.17.1",
|
||||
"chokidar": "^3.5.3",
|
||||
"compression": "^1.7.4",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.20.0",
|
||||
"get-port": "5.1.1",
|
||||
"morgan": "^1.10.0",
|
||||
"morgan": "^1.10.1",
|
||||
"source-map-support": "^0.5.21"
|
||||
},
|
||||
"bin": {
|
||||
@ -1707,9 +1712,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/server-runtime": {
|
||||
"version": "2.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.16.8.tgz",
|
||||
"integrity": "sha512-ZwWOam4GAQTx10t+wK09YuYctd2Koz5Xy/klDgUN3lmTXmwbV0tZU0baiXEqZXrvyD+WDZ4b0ADDW9Df3+dpzA==",
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.17.1.tgz",
|
||||
"integrity": "sha512-d1Vp9FxX4KafB111vP2E5C1fmWzPI+gHZ674L1drq+N8Bp9U6FBspi7GAZSU5K5Kxa4T6UF+aE1gK6pVi9R8sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"@types/cookie": "^0.6.0",
|
||||
@ -2108,7 +2114,8 @@
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"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": {
|
||||
"version": "4.1.12",
|
||||
@ -3123,10 +3130,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vanilla-extract/integration/node_modules/vite": {
|
||||
"version": "5.4.19",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"version": "5.4.20",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
|
||||
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@ -4480,6 +4488,7 @@
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@ -11906,9 +11915,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
|
||||
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
@ -13097,10 +13107,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
23
prisma/migrations/add_workers_tables/migration.sql
Normal file
23
prisma/migrations/add_workers_tables/migration.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- 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,10 +31,13 @@ model Report {
|
||||
timeSheet Json // JSON: Array of timesheet objects
|
||||
stoppages Json // JSON: Array of stoppage records
|
||||
notes String?
|
||||
|
||||
|
||||
// Sheet relations
|
||||
daySheetFor Sheet[] @relation("DayShift")
|
||||
nightSheetFor Sheet[] @relation("NightShift")
|
||||
daySheetFor Sheet[] @relation("DayShift")
|
||||
nightSheetFor Sheet[] @relation("NightShift")
|
||||
|
||||
// Worker relations
|
||||
shiftWorkers ShiftWorker[]
|
||||
}
|
||||
|
||||
model Area {
|
||||
@ -93,25 +96,44 @@ model Equipment {
|
||||
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 {
|
||||
id Int @id @default(autoincrement())
|
||||
dayShiftId Int?
|
||||
nightShiftId Int?
|
||||
status String @default("pending") // 'pending', 'completed'
|
||||
areaId Int
|
||||
dredgerLocationId Int
|
||||
id Int @id @default(autoincrement())
|
||||
dayShiftId Int?
|
||||
nightShiftId Int?
|
||||
status String @default("pending") // 'pending', 'completed'
|
||||
areaId Int
|
||||
dredgerLocationId Int
|
||||
reclamationLocationId Int
|
||||
date String // Store as string in YYYY-MM-DD format
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
date String // Store as string in YYYY-MM-DD format
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
area Area @relation(fields: [areaId], references: [id])
|
||||
dredgerLocation DredgerLocation @relation(fields: [dredgerLocationId], references: [id])
|
||||
area Area @relation(fields: [areaId], references: [id])
|
||||
dredgerLocation DredgerLocation @relation(fields: [dredgerLocationId], references: [id])
|
||||
reclamationLocation ReclamationLocation @relation(fields: [reclamationLocationId], references: [id])
|
||||
dayShift Report? @relation("DayShift", fields: [dayShiftId], references: [id])
|
||||
nightShift Report? @relation("NightShift", fields: [nightShiftId], references: [id])
|
||||
|
||||
dayShift Report? @relation("DayShift", fields: [dayShiftId], references: [id])
|
||||
nightShift Report? @relation("NightShift", fields: [nightShiftId], references: [id])
|
||||
|
||||
@@unique([areaId, dredgerLocationId, reclamationLocationId, date])
|
||||
}
|
||||
|
||||
|
||||
@ -140,6 +140,35 @@ 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(`Created ${areas.length} areas`)
|
||||
console.log(`Created ${dredgerLocations.length} dredger locations`)
|
||||
@ -147,6 +176,7 @@ async function main() {
|
||||
console.log(`Created 1 employee`)
|
||||
console.log(`Created 1 foreman`)
|
||||
console.log(`Created ${equipment.length} equipment records`)
|
||||
console.log(`Created ${workers.length} workers`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user