diff --git a/CARRY_FORWARD_IMPLEMENTATION.md b/CARRY_FORWARD_IMPLEMENTATION.md new file mode 100644 index 0000000..24dec08 --- /dev/null +++ b/CARRY_FORWARD_IMPLEMENTATION.md @@ -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 diff --git a/Dockerfile b/Dockerfile index ccf4332..64cb4ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,39 +54,6 @@ COPY --from=deps --chown=remix:nodejs /app/node_modules ./node_modules RUN mkdir -p /app/data /app/logs && \ chown -R remix:nodejs /app/data /app/logs -# Create startup script -COPY --chown=remix:nodejs < + Edit + + ``` + +## 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) diff --git a/EDIT_ROUTE_COMPLETE_GUIDE.md b/EDIT_ROUTE_COMPLETE_GUIDE.md new file mode 100644 index 0000000..62a3e32 --- /dev/null +++ b/EDIT_ROUTE_COMPLETE_GUIDE.md @@ -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(); +``` + +### 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( + 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.isArray(report.timeSheet) ? (report.timeSheet as any[]).map((entry: any, index: number) => ({ + ...entry, + id: entry.id || `existing-${index}` + })) : [] +); + +const [stoppageEntries, setStoppageEntries] = useState>( + 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 +

Edit Report

+

Update shift details

+``` + +### 10. Update back button +```typescript + + Back to Reports + +``` + +### 11. Replace Step 1 with Locked Fields Display + Pipeline Details +```typescript +{currentStep === 1 && ( +
+ {/* Locked Fields Display */} +
+

Report Information (Cannot be changed)

+
+
+ Date: + {new Date(report.createdDate).toLocaleDateString('en-GB')} +
+
+ Shift: + + {report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} + +
+
+ Area: + {report.area.name} +
+
+ Dredger Location: + {report.dredgerLocation.name} +
+
+ Reclamation Location: + {report.reclamationLocation.name} +
+
+
+ + {/* Editable Pipeline Details - Same as Step 2 from new report */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ {/* Add Reclamation Height and Pipeline Length sections here - same as new report Step 2 */} +
+)} +``` + +### 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 + +``` + +### 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). diff --git a/app/components/DashboardLayout.tsx b/app/components/DashboardLayout.tsx index 632da92..be15088 100644 --- a/app/components/DashboardLayout.tsx +++ b/app/components/DashboardLayout.tsx @@ -312,6 +312,17 @@ function SidebarContent({ Foreman + + + + } + > + Workers + + ); -} \ No newline at end of file +} diff --git a/app/routes/api.check-duplicate-report.ts b/app/routes/api.check-duplicate-report.ts new file mode 100644 index 0000000..d079872 --- /dev/null +++ b/app/routes/api.check-duplicate-report.ts @@ -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 }); + } +}; diff --git a/app/routes/api.equipment-usage.ts b/app/routes/api.equipment-usage.ts new file mode 100644 index 0000000..24fc917 --- /dev/null +++ b/app/routes/api.equipment-usage.ts @@ -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 }); +}; diff --git a/app/routes/api.last-report-data.ts b/app/routes/api.last-report-data.ts new file mode 100644 index 0000000..411d494 --- /dev/null +++ b/app/routes/api.last-report-data.ts @@ -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 }); + } +}; diff --git a/app/routes/api.worker-shifts.ts b/app/routes/api.worker-shifts.ts new file mode 100644 index 0000000..b8b069b --- /dev/null +++ b/app/routes/api.worker-shifts.ts @@ -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 }); +}; diff --git a/app/routes/equipment.tsx b/app/routes/equipment.tsx index c9c5994..6993fe6 100644 --- a/app/routes/equipment.tsx +++ b/app/routes/equipment.tsx @@ -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(null); + const [equipmentUsage, setEquipmentUsage] = useState([]); + 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() {
+
+
+ {/* Equipment Usage Modal */} + {showUsageModal && selectedEquipment && ( +
+
+
+
+

+ Equipment Usage - {selectedEquipment.model} +

+

+ {selectedEquipment.category} #{selectedEquipment.number} +

+
+ +
+ + {/* Date Filters */} +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+
+ + {/* Total Hours Summary */} + {equipmentUsage.length > 0 && ( +
+
+
+ + + +
+

Total Working Hours

+

Across {equipmentUsage.length} shift{equipmentUsage.length !== 1 ? 's' : ''}

+
+
+
+

+ {totalHours.hours}h {totalHours.minutes}m +

+

+ ({totalHours.hours * 60 + totalHours.minutes} minutes) +

+
+
+
+ )} + + {/* Usage List */} +
+ {equipmentUsage.length > 0 ? ( +
+ + + + + + + + + + + + + {equipmentUsage.map((entry: any, index: number) => ( + + + + + + + + + ))} + +
+ Date + + Shift + + Area + + Employee + + Working Hours + + Reason +
+ {new Date(entry.createdDate).toLocaleDateString('en-GB')} + + + {entry.shift.charAt(0).toUpperCase() + entry.shift.slice(1)} + + + {entry.area.name} + + {entry.employee.name} + + {entry.totalHours} + + {entry.reason || '-'} +
+
+ ) : ( +
+ + + +

+ {isLoadingUsage ? 'Loading usage data...' : 'Click "Filter Usage" to view equipment usage history'} +

+
+ )} +
+ +
+ +
+
+
+ )} + {/* Form Modal */} { 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() {
- - - - - - - - - - - - - {sheets.map((sheet) => ( - - - - - - - - + + + + + + + + + - ))} - -
- Date - - Area - - Locations - - Available Shifts - - Status - - Employees - - Actions -
-
- {new Date(sheet.date).toLocaleDateString('en-GB')} -
-
-
{sheet.area}
-
-
-
Dredger: {sheet.dredgerLocation}
-
Reclamation: {sheet.reclamationLocation}
-
-
-
- {sheet.dayReport && ( - - {getShiftIcon('day')} - Day - - )} - {sheet.nightReport && ( - - {getShiftIcon('night')} - Night - - )} -
-
- - {sheet.status === 'completed' ? ( - - - - ) : ( - - - - )} - {sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)} - - -
- {sheet.dayReport && ( -
Day: {sheet.dayReport.employee.name}
- )} - {sheet.nightReport && ( -
Night: {sheet.nightReport.employee.name}
- )} -
-
- -
+ Date + + Area + + Locations + + Available Shifts + + Status + + Employees + + Actions +
+ + + {sheets.map((sheet) => ( + + +
+ {new Date(sheet.date).toLocaleDateString('en-GB')} +
+ + +
{sheet.area}
+ + +
+
Dredger: {sheet.dredgerLocation}
+
Reclamation: {sheet.reclamationLocation}
+
+ + +
+ {sheet.dayReport && ( + + {getShiftIcon('day')} + Day + + )} + {sheet.nightReport && ( + + {getShiftIcon('night')} + Night + + )} +
+ + + + {sheet.status === 'completed' ? ( + + + + ) : ( + + + + )} + {sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)} + + + +
+ {sheet.dayReport && ( +
+ Day: {sheet.dayReport.employee.name} + +
+ )} + {sheet.nightReport && ( +
+ Night: {sheet.nightReport.employee.name} + +
+ )} +
+ + + + + + ))} + +
@@ -496,7 +541,7 @@ export default function ReportSheet() { {sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)} - +
Dredger: @@ -539,12 +584,30 @@ export default function ReportSheet() {
- +
+ + {sheet.dayReport && ( + + )} + {sheet.nightReport && ( + + )} +
))} @@ -567,6 +630,116 @@ export default function ReportSheet() { onClose={handleCloseViewModal} sheet={viewingSheet} /> + + {/* Workers Modal */} + {showWorkersModal && selectedShiftWorkers && ( +
+
+
+

+ {selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)} Shift Workers +

+ +
+ +
+
+
+
+ Shift: + + {selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)} + +
+
+ Date: + + {new Date(selectedShiftWorkers.sheet.date).toLocaleDateString('en-GB')} + +
+
+ Area: + {selectedShiftWorkers.sheet.area} +
+
+ Employee: + + {selectedShiftWorkers.shift === 'day' + ? selectedShiftWorkers.sheet.dayReport?.employee.name + : selectedShiftWorkers.sheet.nightReport?.employee.name} + +
+
+
+ +
+

+ Assigned Workers ({selectedShiftWorkers.workers.length}) +

+ {selectedShiftWorkers.workers.length > 0 ? ( +
+ + + + + + + + + + {selectedShiftWorkers.workers.map((sw: any, index: number) => ( + + + + + + ))} + +
+ # + + Worker Name + + Status +
+ {index + 1} + + {sw.worker.name} + + + {sw.worker.status} + +
+
+ ) : ( +
+ + + +

No workers assigned to this shift

+
+ )} +
+ +
+ +
+
+
+
+ )} ); diff --git a/app/routes/reports.tsx b/app/routes/reports.tsx index b916fa1..793e15c 100644 --- a/app/routes/reports.tsx +++ b/app/routes/reports.tsx @@ -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(); const navigation = useNavigation(); const [searchParams, setSearchParams] = useSearchParams(); - const [editingReport, setEditingReport] = useState(null); const [viewingReport, setViewingReport] = useState(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>([]); - const [stoppageEntries, setStoppageEntries] = useState>([]); - - const isSubmitting = navigation.state === "submitting"; - const isEditing = editingReport !== null; + const [showWorkersModal, setShowWorkersModal] = useState(false); + const [selectedReportWorkers, setSelectedReportWorkers] = useState(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) => { @@ -1340,6 +1178,13 @@ export default function Reports() { > View + {canDuplicateReport(report) ? (
@@ -1386,12 +1231,12 @@ export default function Reports() { )} {canEditReport(report) && ( <> - + @@ -1468,6 +1313,12 @@ export default function Reports() { > View Details + {canDuplicateReport(report) ? ( @@ -1513,12 +1364,12 @@ export default function Reports() { )} {canEditReport(report) && (
- + @@ -1563,31 +1414,6 @@ export default function Reports() { )}
- {/* Edit Form Modal - Only for editing existing reports */} - {isEditing && ( - - )} - {/* View Modal */} + {/* Workers Modal */} + {showWorkersModal && selectedReportWorkers && ( +
+
+
+

+ Workers - Shift #{selectedReportWorkers.id} +

+ +
+ +
+
+
+
+ Shift: + + {selectedReportWorkers.shift.charAt(0).toUpperCase() + selectedReportWorkers.shift.slice(1)} + +
+
+ Date: + + {new Date(selectedReportWorkers.createdDate).toLocaleDateString('en-GB')} + +
+
+ Area: + {selectedReportWorkers.area.name} +
+
+ Employee: + {selectedReportWorkers.employee.name} +
+
+
+ +
+

+ Assigned Workers ({selectedReportWorkers.shiftWorkers?.length || 0}) +

+ {selectedReportWorkers.shiftWorkers && selectedReportWorkers.shiftWorkers.length > 0 ? ( +
+ + + + + + + + + + {selectedReportWorkers.shiftWorkers.map((sw: any, index: number) => ( + + + + + + ))} + +
+ # + + Worker Name + + Status +
+ {index + 1} + + {sw.worker.name} + + + {sw.worker.status} + +
+
+ ) : ( +
+ + + +

No workers assigned to this shift

+
+ )} +
+ +
+ +
+
+
+
+ )} + {/* Toast Notifications */} {toast && ( [{ title: "Edit Report - Phosphat Report" }]; + +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 }); + } + + // Get dropdown data for form + const [foremen, equipment, workers] = await Promise.all([ + 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, + foremen, + equipment, + workers + }); +}; + +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 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 = typeof notes === "string" ? 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 }); + } +}; + +export default function EditReport() { + const { user, report, foremen, equipment, workers } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + + 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 || '', + notes: report.notes || '' + }); + + const [selectedWorkers, setSelectedWorkers] = useState( + report.shiftWorkers?.map((sw: any) => sw.worker.id) || [] + ); + const [workerSearchTerm, setWorkerSearchTerm] = useState(''); + + const [timeSheetEntries, setTimeSheetEntries] = useState>(Array.isArray(report.timeSheet) ? (report.timeSheet as any[]).map((entry: any, index: number) => ({ + ...entry, + id: entry.id || `existing-${index}` + })) : []); + + const [stoppageEntries, setStoppageEntries] = useState>(Array.isArray(report.stoppages) ? (report.stoppages as any[]).map((entry: any, index: number) => ({ + ...entry, + id: entry.id || `existing-${index}` + })) : []); + + const [currentStep, setCurrentStep] = useState(1); + const totalSteps = 3; + + const isSubmitting = navigation.state === "submitting"; + + const updateFormData = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const handleSubmit = (event: React.FormEvent) => { + if (currentStep !== totalSteps) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + + const invalidStoppages = stoppageEntries.filter(entry => + entry.responsible === 'reclamation' && !entry.note.trim() + ); + + if (invalidStoppages.length > 0) { + alert('Please add notes for all reclamation stoppages before submitting.'); + event.preventDefault(); + event.stopPropagation(); + return false; + } + }; + + // Helper functions for time calculations + 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; + + if (from1 && to1) { + const start1 = parseTime(from1); + let end1 = parseTime(to1); + if (end1 < start1) end1 += 24 * 60; + totalMinutes += end1 - start1; + } + + 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 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); + }; + + // Time Sheet management + 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 }; + if (['from1', 'to1', 'from2', 'to2'].includes(field)) { + updatedEntry.total = calculateTimeDifference( + updatedEntry.from1, + updatedEntry.to1, + updatedEntry.from2, + updatedEntry.to2 + ); + } + return updatedEntry; + } + return entry; + })); + }; + + // Auto-calculate equipment counts based on time sheet entries + useEffect(() => { + const counts = { dozers: 0, excavators: 0, loaders: 0 }; + + timeSheetEntries.forEach(entry => { + if (entry.machine) { + const equipmentItem = equipment.find(item => + `${item.model} (${item.number})` === entry.machine + ); + if (equipmentItem) { + const category = equipmentItem.category.toLowerCase(); + if (category.includes('dozer')) { + counts.dozers++; + } else if (category.includes('excavator')) { + counts.excavators++; + } else if (category.includes('loader')) { + counts.loaders++; + } + } + } + }); + + setFormData(prev => ({ + ...prev, + statsDozers: counts.dozers.toString(), + statsExc: counts.excavators.toString(), + statsLoaders: counts.loaders.toString() + })); + }, [timeSheetEntries, equipment]); + + // Stoppage management + const addStoppageEntry = () => { + const newEntry = { + id: Date.now().toString(), + from: '', + to: '', + total: '00:00', + reason: '', + responsible: 'reclamation', + 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 }; + if (['from', 'to'].includes(field)) { + updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to); + } + if (field === 'responsible') { + if (value === 'reclamation') { + updatedEntry.reason = ''; + } + } + return updatedEntry; + } + return entry; + })); + }; + + const nextStep = (event?: React.MouseEvent) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + if (currentStep < totalSteps) { + setCurrentStep(currentStep + 1); + } + }; + + const prevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const getStepTitle = (step: number) => { + switch (step) { + case 1: return "Pipeline Details"; + case 2: return "Equipment & Time Sheet"; + case 3: return "Stoppages & Notes"; + default: return ""; + } + }; + + const isCurrentStepValid = () => { + return true; // All steps are optional for editing + }; + + // 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 ( + +
+ {/* Header */} +
+
+
+

Edit Report

+

Update shift details

+
+ + + + + Back to Reports + +
+
+ + {/* Progress Steps */} +
+
+ {[1, 2, 3].map((step) => ( +
+
+ {step < currentStep ? ( + + + + ) : ( + {step} + )} +
+ {step < totalSteps && ( +
+ )} +
+ ))} +
+
+

{getStepTitle(currentStep)}

+

Step {currentStep} of {totalSteps}

+
+
+ + {/* Form */} + +
+ {/* Step 1: Locked Fields Display + Pipeline Details */} + {currentStep === 1 && ( +
+ {/* Locked Fields Display */} +
+

Report Information (Cannot be changed)

+
+
+ Date: + {new Date(report.createdDate).toLocaleDateString('en-GB')} +
+
+ Shift: + + {report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} + +
+
+ Area: + {report.area.name} +
+
+ Dredger Location: + {report.dredgerLocation.name} +
+
+ Reclamation Location: + {report.reclamationLocation.name} +
+
+
+ + {/* Editable Dredger Line Length and Shore Connection */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+

Reclamation Height

+
+
+ + updateFormData('reclamationHeightBase', 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" + /> +
+
+ + updateFormData('reclamationHeightExtra', 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" + /> +
+
+
+
+

Pipeline Length

+
+
+ + updateFormData('pipelineMain', 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" + /> +
+
+ + updateFormData('pipelineExt1', 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" + /> +
+
+ + updateFormData('pipelineReserve', 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" + /> +
+
+ + updateFormData('pipelineExt2', 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" + /> +
+
+
+
+ )} + + {/* Step 2: Equipment & Time Sheet */} + {currentStep === 2 && ( +
+
+

Equipment Statistics

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Select Workers

+
+
+ 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 && ( +
+ {filteredWorkers.map((worker) => ( + + ))} +
+ )} +
+ {selectedWorkers.length > 0 && ( +
+ {selectedWorkers.map((workerId) => { + const worker = workers.find(w => w.id === workerId); + return worker ? ( + + {worker.name} + + + ) : null; + })} +
+ )} +
+
+ +
+
+

Time Sheet

+ +
+ {timeSheetEntries.length > 0 ? ( +
+ {timeSheetEntries.map((entry) => ( +
+
+
+ + +
+
+ + updateTimeSheetEntry(entry.id, 'from1', 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" + /> +
+
+ + updateTimeSheetEntry(entry.id, 'to1', 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" + /> +
+
+ + updateTimeSheetEntry(entry.id, 'from2', 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" + /> +
+
+ + updateTimeSheetEntry(entry.id, 'to2', 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" + /> +
+
+ + +
+
+ +
+
+
+ + updateTimeSheetEntry(entry.id, 'reason', 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="Reason for downtime (if any)" + /> +
+
+ ))} +
+ ) : ( +
+

No time sheet entries yet. Click "Add Entry" to get started.

+
+ )} +
+
+ )} + + {/* Step 3: Stoppages & Notes */} + {currentStep === 3 && ( +
+
+
+

Dredger Stoppages

+ +
+ {stoppageEntries.length > 0 ? ( +
+ {stoppageEntries.map((entry) => ( +
+
+
+ + updateStoppageEntry(entry.id, 'from', 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" + /> +
+
+ + updateStoppageEntry(entry.id, 'to', 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" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + updateStoppageEntry(entry.id, 'note', e.target.value)} + className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 ${ + entry.responsible === 'reclamation' && !entry.note.trim() ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + placeholder={entry.responsible === 'reclamation' ? 'Notes required for reclamation stoppages' : 'Additional notes'} + /> +
+
+ ))} +
+ ) : ( +
+

No stoppages recorded. Click "Add Stoppage" if there were any.

+
+ )} +
+
+ +