Compare commits

..

8 Commits

Author SHA1 Message Date
a79ff3e729 first after 100 3 2 2025-12-04 11:37:59 +03:00
367a4c9734 first after 100 3 2025-12-04 11:35:38 +03:00
cb0960299d first after 100 2025-12-04 09:05:38 +03:00
b1472a7f72 Your descriptive message about the updatesss 2025-11-03 21:59:20 +03:00
377863b595 Your descriptive message about the update 2025-11-03 21:21:11 +03:00
e3987c2fe6 gg 2025-09-07 03:53:58 +03:00
812e668e17 publish de 222 444p 2025-09-07 03:21:59 +03:00
3fbc0a2093 publish de 222p 2025-09-07 02:38:45 +03:00
27 changed files with 3672 additions and 560 deletions

View File

@ -49,9 +49,6 @@ coverage
ehthumbs.db
Thumbs.db
# Explicitly include start.sh
!start.sh
# Git
.git
.gitignore

30
.env.dokploy Normal file
View 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
View 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"

View 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

View File

@ -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.

View 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)

View 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).

View File

@ -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={

View File

@ -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>
</>

View File

@ -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>
</>

View File

@ -53,4 +53,4 @@ export default function Toast({ message, type, onClose }: ToastProps) {
</div>
</div>
);
}
}

View 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 });
}
};

View 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 });
};

View 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 });
}
};

View 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 });
};

View File

@ -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>

View File

@ -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}

View File

@ -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>
);

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View File

@ -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",

Binary file not shown.

View 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");

View File

@ -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])
}

View File

@ -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()