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
35 changed files with 4290 additions and 477 deletions

227
CARRY_FORWARD_FEATURE.md Normal file
View File

@ -0,0 +1,227 @@
# Carry Forward Feature
## Overview
Added a "CarryTo" functionality that allows users to carry forward (clone) existing reports to today's date. This feature is useful for continuing operations with similar configurations from previous days.
## Features
### ✅ **Core Functionality**
1. **Clone Report**: Creates an exact copy of an existing report with today's date
2. **Smart Validation**: Prevents carrying forward reports from today or future dates
3. **Conflict Prevention**: Checks for existing reports with same configuration for today
4. **Sheet Management**: Automatically manages sheet creation and completion status
5. **User Assignment**: Assigns the carried forward report to the current user
### ✅ **Business Logic**
- **Source Reports**: Only reports from previous days can be carried forward
- **Target Date**: Always creates the new report with today's date and current time
- **Data Preservation**: Copies all report data except stoppages (starts fresh for new day)
- **Sheet Completion**: Automatically marks sheets as "completed" when both day and night shifts exist
## User Interface
### ✅ **Desktop Actions**
```
[View] [Duplicate] [CarryTo] [Edit] [Delete]
```
### ✅ **Mobile Actions**
```
[View Details]
[Duplicate as Night Shift]
[Carry Forward to Today]
[Edit] [Delete]
```
### ✅ **Visual Design**
- **Color**: Purple theme to distinguish from duplicate (green)
- **Icon**: Forward arrow or calendar icon
- **Confirmation**: User confirmation dialog before execution
- **Feedback**: Success/error messages via toast notifications
## Technical Implementation
### ✅ **Validation Logic**
```typescript
const canCarryForwardReport = (report: any) => {
const reportDate = new Date(report.createdDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
reportDate.setHours(0, 0, 0, 0);
// Cannot carry forward reports from today or future
if (reportDate >= today) {
return false;
}
return true;
};
```
### ✅ **Server-Side Processing**
```typescript
// 1. Validate source report exists and is from past
// 2. Check for existing report with same configuration today
// 3. Create new report with today's date
// 4. Manage sheet creation/updates
// 5. Check and update sheet completion status
```
### ✅ **Data Cloning**
```typescript
const carriedReport = await prisma.report.create({
data: {
employeeId: user.id, // Current user
shift: originalReport.shift, // Same shift
areaId: originalReport.areaId,
dredgerLocationId: originalReport.dredgerLocationId,
dredgerLineLength: originalReport.dredgerLineLength,
reclamationLocationId: originalReport.reclamationLocationId,
shoreConnection: originalReport.shoreConnection,
reclamationHeight: originalReport.reclamationHeight,
pipelineLength: originalReport.pipelineLength,
stats: originalReport.stats,
timeSheet: originalReport.timeSheet,
stoppages: [], // Fresh start for new day
notes: originalReport.notes,
createdDate: new Date() // Today's date
}
});
```
## Business Rules
### ✅ **Eligibility Criteria**
1. **Date Restriction**: Source report must be from a previous day
2. **User Access**: All authenticated users can carry forward reports
3. **Conflict Prevention**: Cannot create if same configuration exists for today
4. **Data Integrity**: Maintains referential integrity with areas, locations, etc.
### ✅ **What Gets Copied**
- ✅ Shift type (day/night)
- ✅ Area and location assignments
- ✅ Equipment configurations
- ✅ Pipeline lengths and heights
- ✅ Statistics and personnel counts
- ✅ Time sheet entries
- ✅ Notes and comments
### ✅ **What Gets Reset**
- ❌ Stoppages (starts with empty array)
- ❌ Creation date (set to current date/time)
- ❌ Employee assignment (assigned to current user)
## Sheet Management
### ✅ **Automatic Sheet Creation**
- Creates or updates sheet for today's date
- Links the carried forward report to appropriate shift slot
- Maintains proper sheet structure and relationships
### ✅ **Completion Logic**
```typescript
// Check if sheet has both day and night shifts
if (todaySheet && todaySheet.dayShiftId && todaySheet.nightShiftId) {
await prisma.sheet.update({
where: { id: todaySheet.id },
data: { status: 'completed' }
});
}
```
## Use Cases
### **Operational Scenarios**
1. **Continuous Operations**
- Carry forward yesterday's day shift to today
- Maintain same equipment and location setup
- Start fresh with stoppages for new day
2. **Shift Handover**
- Night shift carries forward day shift configuration
- Ensures consistency in operational setup
- Reduces setup time for similar operations
3. **Recurring Operations**
- Weekly operations with similar patterns
- Monthly maintenance cycles
- Seasonal operational configurations
### **Workflow Examples**
1. **Daily Operations**
```
Yesterday Day Shift → [CarryTo] → Today Day Shift
- Same location, equipment, personnel
- Fresh stoppages tracking
- Current user assignment
```
2. **Shift Completion**
```
Day Shift Carried Forward → Night Shift Created → Sheet Completed
- Automatic sheet status update
- Complete operational cycle
- Ready for reporting
```
## Error Handling
### ✅ **Validation Errors**
- **Future Date**: "Cannot carry forward reports from today or future dates"
- **Duplicate Configuration**: "A [shift] shift report already exists for today with the same location configuration"
- **Missing Report**: "Report not found"
- **System Error**: "Failed to carry forward report"
### ✅ **User Feedback**
- **Success**: "Report carried forward to today as [shift] shift!"
- **Confirmation**: User must confirm before execution
- **Visual Indicators**: Button states show availability
- **Toast Notifications**: Success/error messages
## Security & Permissions
### ✅ **Access Control**
- All authenticated users (auth level 1+) can carry forward reports
- Users can carry forward any report, not just their own
- Carried forward report is assigned to current user
- Maintains audit trail with creation timestamps
### ✅ **Data Validation**
- Server-side validation of all business rules
- Prevents duplicate configurations for same day
- Ensures data integrity and consistency
- Proper error handling and user feedback
## Performance Considerations
### ✅ **Database Operations**
- Single transaction for report creation and sheet management
- Efficient queries for validation checks
- Proper indexing on date and location fields
- Minimal data transfer with focused queries
### ✅ **User Experience**
- Fast execution with immediate feedback
- Clear visual indicators for button availability
- Intuitive confirmation dialogs
- Consistent behavior across desktop and mobile
## Future Enhancements
### **Potential Improvements**
1. **Bulk Carry Forward**: Carry forward multiple reports at once
2. **Scheduled Carry Forward**: Automatic carry forward for recurring operations
3. **Template System**: Save common configurations as templates
4. **Selective Data Copy**: Choose which data elements to carry forward
5. **Carry Forward History**: Track which reports were carried forward from where
### **Advanced Features**
1. **Smart Suggestions**: Suggest reports to carry forward based on patterns
2. **Batch Operations**: Carry forward entire sheets or multiple shifts
3. **Custom Date Selection**: Carry forward to specific future dates
4. **Approval Workflow**: Require approval for certain carry forward operations
5. **Integration**: Connect with scheduling systems for automated carry forward
The Carry Forward feature streamlines operational continuity by allowing users to quickly replicate successful operational configurations from previous days, reducing setup time and ensuring consistency in operations."

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

@ -54,39 +54,6 @@ COPY --from=deps --chown=remix:nodejs /app/node_modules ./node_modules
RUN mkdir -p /app/data /app/logs && \ RUN mkdir -p /app/data /app/logs && \
chown -R remix:nodejs /app/data /app/logs chown -R remix:nodejs /app/data /app/logs
# Create startup script
COPY --chown=remix:nodejs <<EOF /app/start.sh
#!/bin/sh
set -e
echo "Starting Phosphat Report Application..."
# Run database migrations
echo "Running database migrations..."
npx prisma db push --accept-data-loss
# Run seed using production script
echo "Seeding database..."
if [ -f "scripts/seed-production.js" ]; then
echo "Using production seed script..."
node scripts/seed-production.js
else
echo "Production seed script not found, trying alternative methods..."
if [ -f "prisma/seed.js" ]; then
echo "Using JavaScript seed file..."
node prisma/seed.js
else
echo "No seeding method available, skipping..."
fi
fi
echo "Database setup complete. Starting application on port 3000..."
export PORT=3000
exec npx remix-serve ./build/server/index.js
EOF
RUN chmod +x /app/start.sh
USER remix USER remix
EXPOSE 3000 EXPOSE 3000
@ -100,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
# Use dumb-init to handle signals properly # Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/start.sh"] CMD ["sh", "-c", "echo 'Starting Phosphat Report Application...' && npx prisma db push --accept-data-loss && echo 'Seeding database...' && (test -f scripts/seed-production.js && node scripts/seed-production.js || test -f prisma/seed.js && node prisma/seed.js || echo 'No seeding method available, skipping...') && echo 'Database setup complete. Starting application on port 3000...' && exec npx remix-serve ./build/server/index.js"]

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

@ -1,6 +1,6 @@
import { Form, Link, useLocation } from "@remix-run/react"; import { Form, Link, useLocation } from "@remix-run/react";
import type { Employee } from "@prisma/client"; import type { Employee } from "@prisma/client";
import { useState } from "react"; import { useState, useEffect } from "react";
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -9,14 +9,15 @@ interface DashboardLayoutProps {
export default function DashboardLayout({ children, user }: DashboardLayoutProps) { export default function DashboardLayout({ children, user }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Initialize from localStorage if available
if (typeof window !== 'undefined') { // Initialize sidebar state from localStorage after hydration
useEffect(() => {
const saved = localStorage.getItem('sidebar-collapsed'); const saved = localStorage.getItem('sidebar-collapsed');
return saved ? JSON.parse(saved) : false; if (saved) {
setSidebarCollapsed(JSON.parse(saved));
} }
return false; }, []);
});
const toggleSidebar = () => setSidebarOpen(!sidebarOpen); const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
@ -24,9 +25,7 @@ export default function DashboardLayout({ children, user }: DashboardLayoutProps
const newCollapsed = !sidebarCollapsed; const newCollapsed = !sidebarCollapsed;
setSidebarCollapsed(newCollapsed); setSidebarCollapsed(newCollapsed);
// Persist to localStorage // Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('sidebar-collapsed', JSON.stringify(newCollapsed)); localStorage.setItem('sidebar-collapsed', JSON.stringify(newCollapsed));
}
}; };
return ( return (
@ -313,6 +312,17 @@ function SidebarContent({
Foreman Foreman
</NavItem> </NavItem>
<NavItem
to="/workers"
icon={
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
}
>
Workers
</NavItem>
<NavItem <NavItem
to="/employees" to="/employees"
icon={ icon={

View File

@ -694,7 +694,9 @@ function ReportSheetNotes({ report }: { report: any }) {
</div> </div>
<div className="border-2 border-black mb-4 min-h-[100px]"> <div className="border-2 border-black mb-4 min-h-[100px]">
<div className="p-4 text-center"> <div className="p-4 text-center">
<pre>
{report.notes || 'No additional notes'} {report.notes || 'No additional notes'}
</pre>
</div> </div>
</div> </div>
</> </>

View File

@ -475,7 +475,9 @@ function ReportNotes({ report }: { report: any }) {
</div> </div>
<div className="border-2 border-black mb-4 min-h-[100px]"> <div className="border-2 border-black mb-4 min-h-[100px]">
<div className="p-4 text-center"> <div className="p-4 text-center">
<pre>
{report.notes || 'No additional notes'} {report.notes || 'No additional notes'}
</pre>
</div> </div>
</div> </div>
</> </>

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

@ -8,7 +8,7 @@ import Toast from "~/components/Toast";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Areas Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Areas Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);

View File

@ -5,7 +5,7 @@ import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout"; import DashboardLayout from "~/components/DashboardLayout";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Dashboard - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Dashboard - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1); const user = await requireAuthLevel(request, 1);
@ -51,7 +51,7 @@ export default function Dashboard() {
Welcome back, {user.name}! Welcome back, {user.name}!
</h2> </h2>
<p className="text-sm sm:text-base text-gray-600"> <p className="text-sm sm:text-base text-gray-600">
Here's what's happening with your phosphat operations today. Here's what's happening with your allhaffer operations today.
</p> </p>
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@ import Toast from "~/components/Toast";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Dredger Locations Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Dredger Locations Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);

View File

@ -9,7 +9,7 @@ import { useState, useEffect } from "react";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Employee Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Employee Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);

View File

@ -8,7 +8,7 @@ import Toast from "~/components/Toast";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Equipment Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Equipment Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);
@ -109,6 +109,13 @@ export default function Equipment() {
const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null); const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showUsageModal, setShowUsageModal] = useState(false);
const [selectedEquipment, setSelectedEquipment] = useState<any>(null);
const [equipmentUsage, setEquipmentUsage] = useState<any[]>([]);
const [totalHours, setTotalHours] = useState({ hours: 0, minutes: 0 });
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === "submitting";
const isEditing = editingEquipment !== null; const isEditing = editingEquipment !== null;
@ -139,6 +146,63 @@ export default function Equipment() {
setEditingEquipment(null); setEditingEquipment(null);
}; };
const handleViewUsage = (item: any) => {
setSelectedEquipment(item);
setShowUsageModal(true);
setDateFrom('');
setDateTo('');
setEquipmentUsage([]);
setTotalHours({ hours: 0, minutes: 0 });
};
const handleCloseUsageModal = () => {
setShowUsageModal(false);
setSelectedEquipment(null);
setEquipmentUsage([]);
setDateFrom('');
setDateTo('');
setTotalHours({ hours: 0, minutes: 0 });
};
const calculateTotalHours = (usage: any[]) => {
let totalMinutes = 0;
usage.forEach((entry: any) => {
const [hours, minutes] = entry.totalHours.split(':').map(Number);
totalMinutes += hours * 60 + minutes;
});
return {
hours: Math.floor(totalMinutes / 60),
minutes: totalMinutes % 60
};
};
const handleFilterUsage = async () => {
if (!selectedEquipment) return;
setIsLoadingUsage(true);
try {
const params = new URLSearchParams({
category: selectedEquipment.category,
model: selectedEquipment.model,
number: selectedEquipment.number.toString()
});
if (dateFrom) params.append('dateFrom', dateFrom);
if (dateTo) params.append('dateTo', dateTo);
const response = await fetch(`/api/equipment-usage?${params.toString()}`);
const data = await response.json();
if (data.usage) {
setEquipmentUsage(data.usage);
setTotalHours(calculateTotalHours(data.usage));
}
} catch (error) {
setToast({ message: "Failed to load equipment usage", type: "error" });
} finally {
setIsLoadingUsage(false);
}
};
const getCategoryBadge = (category: string) => { const getCategoryBadge = (category: string) => {
const colors = { const colors = {
Dozer: "bg-yellow-100 text-yellow-800", Dozer: "bg-yellow-100 text-yellow-800",
@ -229,6 +293,12 @@ export default function Equipment() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button
onClick={() => handleViewUsage(item)}
className="text-teal-600 hover:text-teal-900 transition-colors duration-150"
>
View Usage
</button>
<button <button
onClick={() => handleEdit(item)} onClick={() => handleEdit(item)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150" className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
@ -280,6 +350,12 @@ export default function Equipment() {
</div> </div>
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<button
onClick={() => handleViewUsage(item)}
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
>
View Usage
</button>
<button <button
onClick={() => handleEdit(item)} onClick={() => handleEdit(item)}
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150" className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
@ -329,6 +405,174 @@ export default function Equipment() {
)} )}
</div> </div>
{/* Equipment Usage Modal */}
{showUsageModal && selectedEquipment && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-full max-w-5xl shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-medium text-gray-900">
Equipment Usage - {selectedEquipment.model}
</h3>
<p className="text-sm text-gray-500">
{selectedEquipment.category} #{selectedEquipment.number}
</p>
</div>
<button
onClick={handleCloseUsageModal}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Date Filters */}
<div className="bg-gray-50 p-4 rounded-lg mb-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label htmlFor="usageDateFrom" className="block text-sm font-medium text-gray-700 mb-1">
From Date
</label>
<input
type="date"
id="usageDateFrom"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={new Date().toISOString().split('T')[0]}
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label htmlFor="usageDateTo" className="block text-sm font-medium text-gray-700 mb-1">
To Date
</label>
<input
type="date"
id="usageDateTo"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
max={new Date().toISOString().split('T')[0]}
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="flex items-end">
<button
onClick={handleFilterUsage}
disabled={isLoadingUsage}
className="w-full px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isLoadingUsage ? 'Loading...' : 'Filter Usage'}
</button>
</div>
</div>
</div>
{/* Total Hours Summary */}
{equipmentUsage.length > 0 && (
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-8 w-8 text-indigo-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm font-medium text-indigo-900">Total Working Hours</p>
<p className="text-xs text-indigo-700">Across {equipmentUsage.length} shift{equipmentUsage.length !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-indigo-900">
{totalHours.hours}h {totalHours.minutes}m
</p>
<p className="text-xs text-indigo-700">
({totalHours.hours * 60 + totalHours.minutes} minutes)
</p>
</div>
</div>
</div>
)}
{/* Usage List */}
<div className="max-h-96 overflow-y-auto">
{equipmentUsage.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Shift
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Area
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Employee
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Working Hours
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reason
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{equipmentUsage.map((entry: any, index: number) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{new Date(entry.createdDate).toLocaleDateString('en-GB')}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${entry.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
{entry.shift.charAt(0).toUpperCase() + entry.shift.slice(1)}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{entry.area.name}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{entry.employee.name}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-indigo-600">
{entry.totalHours}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{entry.reason || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p className="mt-2 text-sm text-gray-500">
{isLoadingUsage ? 'Loading usage data...' : 'Click "Filter Usage" to view equipment usage history'}
</p>
</div>
)}
</div>
<div className="flex justify-end mt-4 pt-4 border-t border-gray-200">
<button
onClick={handleCloseUsageModal}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Form Modal */} {/* Form Modal */}
<FormModal <FormModal
isOpen={showModal} isOpen={showModal}

View File

@ -8,7 +8,7 @@ import Toast from "~/components/Toast";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Foreman Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Foreman Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);

View File

@ -8,7 +8,7 @@ import Toast from "~/components/Toast";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Reclamation Locations Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Reclamation Locations Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2); const user = await requireAuthLevel(request, 2);

View File

@ -7,7 +7,7 @@ import ReportSheetViewModal from "~/components/ReportSheetViewModal";
import { useState } from "react"; import { useState } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Report Sheets - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Report Sheets - Alhaffer Report System" }];
interface ReportSheet { interface ReportSheet {
id: string; id: string;
@ -73,7 +73,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
employee: { select: { name: true } }, employee: { select: { name: true } },
area: { select: { name: true } }, area: { select: { name: true } },
dredgerLocation: { select: { name: true, class: true } }, dredgerLocation: { select: { name: true, class: true } },
reclamationLocation: { select: { name: true } } reclamationLocation: { select: { name: true } },
shiftWorkers: {
include: {
worker: { select: { id: true, name: true, status: true } }
}
}
} }
}, },
nightShift: { nightShift: {
@ -81,7 +86,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
employee: { select: { name: true } }, employee: { select: { name: true } },
area: { select: { name: true } }, area: { select: { name: true } },
dredgerLocation: { select: { name: true, class: true } }, dredgerLocation: { select: { name: true, class: true } },
reclamationLocation: { select: { name: true } } reclamationLocation: { select: { name: true } },
shiftWorkers: {
include: {
worker: { select: { id: true, name: true, status: true } }
}
}
} }
} }
} }
@ -149,6 +159,8 @@ export default function ReportSheet() {
const [showViewModal, setShowViewModal] = useState(false); const [showViewModal, setShowViewModal] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showWorkersModal, setShowWorkersModal] = useState(false);
const [selectedShiftWorkers, setSelectedShiftWorkers] = useState<{ shift: string; workers: any[]; sheet: any } | null>(null);
const handleView = (sheet: ReportSheet) => { const handleView = (sheet: ReportSheet) => {
setViewingSheet(sheet); setViewingSheet(sheet);
@ -160,6 +172,21 @@ export default function ReportSheet() {
setViewingSheet(null); setViewingSheet(null);
}; };
const handleViewWorkers = (sheet: any, shift: 'day' | 'night') => {
const shiftReport = shift === 'day' ? sheet.dayReport : sheet.nightReport;
setSelectedShiftWorkers({
shift,
workers: shiftReport?.shiftWorkers || [],
sheet
});
setShowWorkersModal(true);
};
const handleCloseWorkersModal = () => {
setShowWorkersModal(false);
setSelectedShiftWorkers(null);
};
// Filter functions // Filter functions
const handleFilterChange = (filterName: string, value: string) => { const handleFilterChange = (filterName: string, value: string) => {
const newSearchParams = new URLSearchParams(searchParams); const newSearchParams = new URLSearchParams(searchParams);
@ -444,12 +471,30 @@ export default function ReportSheet() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-900 space-y-1">
{sheet.dayReport && ( {sheet.dayReport && (
<div>Day: {sheet.dayReport.employee.name}</div> <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 && ( {sheet.nightReport && (
<div>Night: {sheet.nightReport.employee.name}</div> <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> </div>
</td> </td>
@ -539,12 +584,30 @@ export default function ReportSheet() {
</div> </div>
</div> </div>
<div className="flex flex-col space-y-2">
<button <button
onClick={() => handleView(sheet)} onClick={() => handleView(sheet)}
className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150" className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150"
> >
View Sheet Details View Sheet Details
</button> </button>
{sheet.dayReport && (
<button
onClick={() => handleViewWorkers(sheet, 'day')}
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
>
Day Workers ({sheet.dayReport.shiftWorkers?.length || 0})
</button>
)}
{sheet.nightReport && (
<button
onClick={() => handleViewWorkers(sheet, 'night')}
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
>
Night Workers ({sheet.nightReport.shiftWorkers?.length || 0})
</button>
)}
</div>
</div> </div>
))} ))}
</div> </div>
@ -567,6 +630,116 @@ export default function ReportSheet() {
onClose={handleCloseViewModal} onClose={handleCloseViewModal}
sheet={viewingSheet} sheet={viewingSheet}
/> />
{/* Workers Modal */}
{showWorkersModal && selectedShiftWorkers && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)} Shift Workers
</h3>
<button
onClick={handleCloseWorkersModal}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Shift:</span>
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getShiftBadge(selectedShiftWorkers.shift)}`}>
{selectedShiftWorkers.shift.charAt(0).toUpperCase() + selectedShiftWorkers.shift.slice(1)}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Date:</span>
<span className="ml-2 text-gray-900">
{new Date(selectedShiftWorkers.sheet.date).toLocaleDateString('en-GB')}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Area:</span>
<span className="ml-2 text-gray-900">{selectedShiftWorkers.sheet.area}</span>
</div>
<div>
<span className="font-medium text-gray-700">Employee:</span>
<span className="ml-2 text-gray-900">
{selectedShiftWorkers.shift === 'day'
? selectedShiftWorkers.sheet.dayReport?.employee.name
: selectedShiftWorkers.sheet.nightReport?.employee.name}
</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Assigned Workers ({selectedShiftWorkers.workers.length})
</h4>
{selectedShiftWorkers.workers.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
#
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Worker Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedShiftWorkers.workers.map((sw: any, index: number) => (
<tr key={sw.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{index + 1}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{sw.worker.name}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${sw.worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{sw.worker.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p className="mt-2 text-sm text-gray-500">No workers assigned to this shift</p>
</div>
)}
</div>
<div className="flex justify-end pt-4">
<button
onClick={handleCloseWorkersModal}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
</DashboardLayout> </DashboardLayout>
); );

View File

@ -10,7 +10,7 @@ import { useState, useEffect } from "react";
import { manageSheet, removeFromSheet } from "~/utils/sheet.server"; import { manageSheet, removeFromSheet } from "~/utils/sheet.server";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Reports Management - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Reports Management - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1); // All employees can access reports const user = await requireAuthLevel(request, 1); // All employees can access reports
@ -75,7 +75,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
employee: { select: { name: true } }, employee: { select: { name: true } },
area: { select: { name: true } }, area: { select: { name: true } },
dredgerLocation: { select: { name: true, class: true } }, dredgerLocation: { select: { name: true, class: true } },
reclamationLocation: { select: { name: true } } reclamationLocation: { select: { name: true } },
shiftWorkers: {
include: {
worker: { select: { id: true, name: true, status: true } }
}
}
} }
}); });
@ -457,6 +462,112 @@ export const action = async ({ request }: ActionFunctionArgs) => {
} }
} }
if (intent === "carryTo") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid report ID" } }, { status: 400 });
}
// Get the original report with all its data
const originalReport = await prisma.report.findUnique({
where: { id: parseInt(id) },
include: {
area: true,
dredgerLocation: true,
reclamationLocation: true
}
});
if (!originalReport) {
return json({ errors: { form: "Report not found" } }, { status: 404 });
}
// Check if report is from today or future (cannot carry forward)
const reportDate = new Date(originalReport.createdDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
reportDate.setHours(0, 0, 0, 0);
if (reportDate >= today) {
return json({ errors: { form: "Cannot carry forward reports from today or future dates" } }, { status: 400 });
}
// Check if a report with same shift, area, and locations already exists for today
const todayString = today.toISOString().split('T')[0];
const existingTodayReport = await prisma.report.findFirst({
where: {
createdDate: {
gte: today,
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000)
},
shift: originalReport.shift,
areaId: originalReport.areaId,
dredgerLocationId: originalReport.dredgerLocationId,
reclamationLocationId: originalReport.reclamationLocationId
}
});
if (existingTodayReport) {
return json({ errors: { form: `A ${originalReport.shift} shift report already exists for today with the same location configuration` } }, { status: 400 });
}
try {
// Create the carried forward report with today's date
const carriedReport = await prisma.report.create({
data: {
employeeId: user.id, // Assign to current user
shift: originalReport.shift,
areaId: originalReport.areaId,
dredgerLocationId: originalReport.dredgerLocationId,
dredgerLineLength: originalReport.dredgerLineLength,
reclamationLocationId: originalReport.reclamationLocationId,
shoreConnection: originalReport.shoreConnection,
reclamationHeight: originalReport.reclamationHeight,
pipelineLength: originalReport.pipelineLength,
stats: originalReport.stats,
timeSheet: originalReport.timeSheet,
stoppages: [], // Empty stoppages array for new day
notes: originalReport.notes,
createdDate: new Date() // Set to current date/time
}
});
// Manage sheet for the new report
await manageSheet(
carriedReport.id,
originalReport.shift,
originalReport.areaId,
originalReport.dredgerLocationId,
originalReport.reclamationLocationId,
carriedReport.createdDate
);
// Check if the sheet should be marked as completed
const todaySheet = await prisma.sheet.findUnique({
where: {
areaId_dredgerLocationId_reclamationLocationId_date: {
areaId: originalReport.areaId,
dredgerLocationId: originalReport.dredgerLocationId,
reclamationLocationId: originalReport.reclamationLocationId,
date: todayString
}
}
});
// If sheet has both day and night shifts, mark as completed
if (todaySheet && todaySheet.dayShiftId && todaySheet.nightShiftId) {
await prisma.sheet.update({
where: { id: todaySheet.id },
data: { status: 'completed' }
});
}
return json({ success: `Report carried forward to today as ${originalReport.shift} shift!` });
} catch (error) {
console.error('CarryTo error:', error);
return json({ errors: { form: "Failed to carry forward report" } }, { status: 400 });
}
}
if (intent === "delete") { if (intent === "delete") {
if (typeof id !== "string") { if (typeof id !== "string") {
return json({ errors: { form: "Invalid report ID" } }, { status: 400 }); return json({ errors: { form: "Invalid report ID" } }, { status: 400 });
@ -525,36 +636,12 @@ export default function Reports() {
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const navigation = useNavigation(); const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [editingReport, setEditingReport] = useState<any>(null);
const [viewingReport, setViewingReport] = useState<any>(null); const [viewingReport, setViewingReport] = useState<any>(null);
const [showModal, setShowModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false); const [showViewModal, setShowViewModal] = useState(false);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showWorkersModal, setShowWorkersModal] = useState(false);
// Dynamic arrays state for editing only const [selectedReportWorkers, setSelectedReportWorkers] = useState<any>(null);
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
id: string,
machine: string,
from1: string,
to1: string,
from2: string,
to2: string,
total: string,
reason: string
}>>([]);
const [stoppageEntries, setStoppageEntries] = useState<Array<{
id: string,
from: string,
to: string,
total: string,
reason: string,
responsible: string,
note: string
}>>([]);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingReport !== null;
// Handle success/error messages from URL params and action data // Handle success/error messages from URL params and action data
useEffect(() => { useEffect(() => {
@ -571,8 +658,6 @@ export default function Reports() {
window.history.replaceState({}, '', '/reports'); window.history.replaceState({}, '', '/reports');
} else if (actionData?.success) { } else if (actionData?.success) {
setToast({ message: actionData.success, type: "success" }); setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingReport(null);
} else if (actionData?.errors?.form) { } else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" }); setToast({ message: actionData.errors.form, type: "error" });
} }
@ -583,160 +668,19 @@ export default function Reports() {
setShowViewModal(true); setShowViewModal(true);
}; };
const handleEdit = (report: any) => {
setEditingReport(report);
// Load existing timesheet and stoppages data
setTimeSheetEntries(Array.isArray(report.timeSheet) ? report.timeSheet : []);
setStoppageEntries(Array.isArray(report.stoppages) ? report.stoppages : []);
setShowModal(true);
};
// Remove handleAdd since we're using a separate page
const handleCloseModal = () => {
setShowModal(false);
setEditingReport(null);
setTimeSheetEntries([]);
setStoppageEntries([]);
};
const handleCloseViewModal = () => { const handleCloseViewModal = () => {
setShowViewModal(false); setShowViewModal(false);
setViewingReport(null); setViewingReport(null);
}; };
// Helper function to calculate time difference in hours:minutes format const handleViewWorkers = (report: any) => {
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => { setSelectedReportWorkers(report);
if (!from1 || !to1) return "00:00"; setShowWorkersModal(true);
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}; };
const formatTime = (minutes: number) => { const handleCloseWorkersModal = () => {
const hours = Math.floor(minutes / 60); setShowWorkersModal(false);
const mins = minutes % 60; setSelectedReportWorkers(null);
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 calculateStoppageTime = (from: string, to: string) => {
if (!from || !to) return "00:00";
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const formatTime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
};
const startMinutes = parseTime(from);
let endMinutes = parseTime(to);
if (endMinutes < startMinutes)
endMinutes += 24 * 60;
const totalMinutes = Math.max(0, endMinutes - startMinutes);
return formatTime(totalMinutes);
};
// TimeSheet management functions
const addTimeSheetEntry = () => {
const newEntry = {
id: Date.now().toString(),
machine: '',
from1: '',
to1: '',
from2: '',
to2: '',
total: '00:00',
reason: ''
};
setTimeSheetEntries([...timeSheetEntries, newEntry]);
};
const removeTimeSheetEntry = (id: string) => {
setTimeSheetEntries(timeSheetEntries.filter(entry => entry.id !== id));
};
const updateTimeSheetEntry = (id: string, field: string, value: string) => {
setTimeSheetEntries(timeSheetEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
// Auto-calculate total when time fields change
if (['from1', 'to1', 'from2', 'to2'].includes(field)) {
updatedEntry.total = calculateTimeDifference(
updatedEntry.from1,
updatedEntry.to1,
updatedEntry.from2,
updatedEntry.to2
);
}
return updatedEntry;
}
return entry;
}));
};
// Stoppage management functions
const addStoppageEntry = () => {
const newEntry = {
id: Date.now().toString(),
from: '',
to: '',
total: '00:00',
reason: '',
responsible: '',
note: ''
};
setStoppageEntries([...stoppageEntries, newEntry]);
};
const removeStoppageEntry = (id: string) => {
setStoppageEntries(stoppageEntries.filter(entry => entry.id !== id));
};
const updateStoppageEntry = (id: string, field: string, value: string) => {
setStoppageEntries(stoppageEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
// Auto-calculate total when time fields change
if (['from', 'to'].includes(field)) {
updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to);
}
return updatedEntry;
}
return entry;
}));
}; };
const getShiftBadge = (shift: string) => { const getShiftBadge = (shift: string) => {
@ -781,6 +725,22 @@ export default function Reports() {
return true; return true;
}; };
const canCarryForwardReport = (report: any) => {
// Check if report is from today or future (cannot carry forward)
const reportDate = new Date(report.createdDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
reportDate.setHours(0, 0, 0, 0);
// If report is from today or future, don't allow carry forward
if (reportDate >= today) {
return false;
}
// All users (auth level 1+) can carry forward reports
return true;
};
const isReportTooOld = (report: any) => { const isReportTooOld = (report: any) => {
const reportDate = new Date(report.createdDate); const reportDate = new Date(report.createdDate);
const dayBeforeToday = new Date(); const dayBeforeToday = new Date();
@ -1218,6 +1178,13 @@ export default function Reports() {
> >
View View
</button> </button>
<button
onClick={() => handleViewWorkers(report)}
className="text-teal-600 hover:text-teal-900 transition-colors duration-150"
title="View workers"
>
Workers ({report.shiftWorkers?.length || 0})
</button>
{canDuplicateReport(report) ? ( {canDuplicateReport(report) ? (
<Form method="post" className="inline"> <Form method="post" className="inline">
<input type="hidden" name="intent" value="duplicate" /> <input type="hidden" name="intent" value="duplicate" />
@ -1244,14 +1211,32 @@ export default function Reports() {
Duplicate Duplicate
</span> </span>
) : null} ) : null}
{canCarryForwardReport(report) && (
<Form method="post" className="inline">
<input type="hidden" name="intent" value="carryTo" />
<input type="hidden" name="id" value={report.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm(`Are you sure you want to carry forward this ${report.shift} shift to today? This will create a new report with today's date.`)) {
e.preventDefault();
}
}}
className="text-purple-600 hover:text-purple-900 transition-colors duration-150"
title="Carry forward to today"
>
CarryTo
</button>
</Form>
)}
{canEditReport(report) && ( {canEditReport(report) && (
<> <>
<button <Link
onClick={() => handleEdit(report)} to={`/reports/${report.id}/edit`}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150" className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
> >
Edit Edit
</button> </Link>
<Form method="post" className="inline"> <Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" /> <input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={report.id} /> <input type="hidden" name="id" value={report.id} />
@ -1328,6 +1313,12 @@ export default function Reports() {
> >
View Details View Details
</button> </button>
<button
onClick={() => handleViewWorkers(report)}
className="w-full text-center px-3 py-2 text-sm text-teal-600 bg-teal-50 rounded-md hover:bg-teal-100 transition-colors duration-150"
>
View Workers ({report.shiftWorkers?.length || 0})
</button>
{canDuplicateReport(report) ? ( {canDuplicateReport(report) ? (
<Form method="post" className="w-full"> <Form method="post" className="w-full">
<input type="hidden" name="intent" value="duplicate" /> <input type="hidden" name="intent" value="duplicate" />
@ -1354,14 +1345,31 @@ export default function Reports() {
Duplicate as {report.shift === 'day' ? 'Night' : 'Day'} Shift (Too Old) Duplicate as {report.shift === 'day' ? 'Night' : 'Day'} Shift (Too Old)
</button> </button>
) : null} ) : null}
{canCarryForwardReport(report) && (
<Form method="post" className="w-full">
<input type="hidden" name="intent" value="carryTo" />
<input type="hidden" name="id" value={report.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm(`Are you sure you want to carry forward this ${report.shift} shift to today? This will create a new report with today's date.`)) {
e.preventDefault();
}
}}
className="w-full text-center px-3 py-2 text-sm text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 transition-colors duration-150"
>
Carry Forward to Today
</button>
</Form>
)}
{canEditReport(report) && ( {canEditReport(report) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <Link
onClick={() => handleEdit(report)} 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" className="flex-1 text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
> >
Edit Edit
</button> </Link>
<Form method="post" className="flex-1"> <Form method="post" className="flex-1">
<input type="hidden" name="intent" value="delete" /> <input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={report.id} /> <input type="hidden" name="id" value={report.id} />
@ -1406,31 +1414,6 @@ export default function Reports() {
)} )}
</div> </div>
{/* Edit Form Modal - Only for editing existing reports */}
{isEditing && (
<ReportFormModal
isOpen={showModal}
onClose={handleCloseModal}
isEditing={isEditing}
isSubmitting={isSubmitting}
editingReport={editingReport}
actionData={actionData}
areas={areas}
dredgerLocations={dredgerLocations}
reclamationLocations={reclamationLocations}
foremen={foremen}
equipment={equipment}
timeSheetEntries={timeSheetEntries}
stoppageEntries={stoppageEntries}
addTimeSheetEntry={addTimeSheetEntry}
removeTimeSheetEntry={removeTimeSheetEntry}
updateTimeSheetEntry={updateTimeSheetEntry}
addStoppageEntry={addStoppageEntry}
removeStoppageEntry={removeStoppageEntry}
updateStoppageEntry={updateStoppageEntry}
/>
)}
{/* View Modal */} {/* View Modal */}
<ReportViewModal <ReportViewModal
isOpen={showViewModal} isOpen={showViewModal}
@ -1438,6 +1421,112 @@ export default function Reports() {
report={viewingReport} report={viewingReport}
/> />
{/* Workers Modal */}
{showWorkersModal && selectedReportWorkers && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
Workers - Shift #{selectedReportWorkers.id}
</h3>
<button
onClick={handleCloseWorkersModal}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Shift:</span>
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getShiftBadge(selectedReportWorkers.shift)}`}>
{selectedReportWorkers.shift.charAt(0).toUpperCase() + selectedReportWorkers.shift.slice(1)}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Date:</span>
<span className="ml-2 text-gray-900">
{new Date(selectedReportWorkers.createdDate).toLocaleDateString('en-GB')}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Area:</span>
<span className="ml-2 text-gray-900">{selectedReportWorkers.area.name}</span>
</div>
<div>
<span className="font-medium text-gray-700">Employee:</span>
<span className="ml-2 text-gray-900">{selectedReportWorkers.employee.name}</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Assigned Workers ({selectedReportWorkers.shiftWorkers?.length || 0})
</h4>
{selectedReportWorkers.shiftWorkers && selectedReportWorkers.shiftWorkers.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
#
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Worker Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedReportWorkers.shiftWorkers.map((sw: any, index: number) => (
<tr key={sw.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{index + 1}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{sw.worker.name}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${sw.worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{sw.worker.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p className="mt-2 text-sm text-gray-500">No workers assigned to this shift</p>
</div>
)}
</div>
<div className="flex justify-end pt-4">
<button
onClick={handleCloseWorkersModal}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
{/* Toast Notifications */} {/* Toast Notifications */}
{toast && ( {toast && (
<Toast <Toast

File diff suppressed because it is too large Load Diff

View File

@ -7,18 +7,19 @@ import { useState, useEffect } from "react";
import { manageSheet } from "~/utils/sheet.server"; import { manageSheet } from "~/utils/sheet.server";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "New Report - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "New Report - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1); // All employees can create reports const user = await requireAuthLevel(request, 1); // All employees can create reports
// Get dropdown data for form // Get dropdown data for form
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([ const [areas, dredgerLocations, reclamationLocations, foremen, equipment, workers] = await Promise.all([
prisma.area.findMany({ orderBy: { name: 'asc' } }), prisma.area.findMany({ orderBy: { name: 'asc' } }),
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }), prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }), prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.foreman.findMany({ orderBy: { name: 'asc' } }), prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }) prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
prisma.worker.findMany({ where: { status: 'active' }, orderBy: { name: 'asc' } })
]); ]);
return json({ return json({
@ -27,7 +28,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
dredgerLocations, dredgerLocations,
reclamationLocations, reclamationLocations,
foremen, foremen,
equipment equipment,
workers
}); });
}; };
@ -39,6 +41,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// Debug logging // Debug logging
console.log("Form data received:", Object.fromEntries(formData.entries())); console.log("Form data received:", Object.fromEntries(formData.entries()));
const createdDate = formData.get("createdDate");
const shift = formData.get("shift"); const shift = formData.get("shift");
const areaId = formData.get("areaId"); const areaId = formData.get("areaId");
const dredgerLocationId = formData.get("dredgerLocationId"); const dredgerLocationId = formData.get("dredgerLocationId");
@ -59,12 +62,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const statsLoaders = formData.get("statsLoaders"); const statsLoaders = formData.get("statsLoaders");
const statsForeman = formData.get("statsForeman"); const statsForeman = formData.get("statsForeman");
const statsLaborer = formData.get("statsLaborer"); const statsLaborer = formData.get("statsLaborer");
const workersListData = formData.get("workersList");
const timeSheetData = formData.get("timeSheetData"); const timeSheetData = formData.get("timeSheetData");
const stoppagesData = formData.get("stoppagesData"); const stoppagesData = formData.get("stoppagesData");
// Validation // Validation
// console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection }); // console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection });
if (typeof createdDate !== "string" || !createdDate) {
return json({ errors: { createdDate: "Report date is required" } }, { status: 400 });
}
if (typeof shift !== "string" || !["day", "night"].includes(shift)) { if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
console.log("Shift validation failed:", shift); console.log("Shift validation failed:", shift);
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 }); return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
@ -89,6 +96,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// Parse JSON arrays // Parse JSON arrays
let timeSheet = []; let timeSheet = [];
let stoppages = []; let stoppages = [];
let workersList = [];
if (timeSheetData && typeof timeSheetData === "string") { if (timeSheetData && typeof timeSheetData === "string") {
try { try {
@ -106,9 +114,49 @@ 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;
const shiftText = shift === 'day' ? 'Day' : 'Night';
let automaticNotes = [];
// Add Extension 1 note if value > 0
if (ext1Value > 0) {
automaticNotes.push(`Main Extension ${ext1Value}m ${shiftText}`);
}
// Add Extension 2 note if value > 0
if (ext2Value > 0) {
automaticNotes.push(`Reserve Extension ${ext2Value}m ${shiftText}`);
}
// Combine automatic notes with user notes
let finalNotes = notes || '';
if (automaticNotes.length > 0) {
const automaticNotesText = automaticNotes.join(', ');
if (finalNotes.trim()) {
finalNotes = `${automaticNotesText}.\n${finalNotes}`;
} else {
finalNotes = automaticNotesText;
}
}
// Parse the selected date
const reportDate = new Date(createdDate);
const report = await prisma.report.create({ const report = await prisma.report.create({
data: { data: {
employeeId: user.id, employeeId: user.id,
createdDate: reportDate,
shift, shift,
areaId: parseInt(areaId), areaId: parseInt(areaId),
dredgerLocationId: parseInt(dredgerLocationId), dredgerLocationId: parseInt(dredgerLocationId),
@ -121,9 +169,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}, },
pipelineLength: { pipelineLength: {
main: parseInt(pipelineMain as string) || 0, main: parseInt(pipelineMain as string) || 0,
ext1: parseInt(pipelineExt1 as string) || 0, ext1: ext1Value,
reserve: parseInt(pipelineReserve as string) || 0, reserve: parseInt(pipelineReserve as string) || 0,
ext2: parseInt(pipelineExt2 as string) || 0 ext2: ext2Value
}, },
stats: { stats: {
Dozers: parseInt(statsDozers as string) || 0, Dozers: parseInt(statsDozers as string) || 0,
@ -134,10 +182,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}, },
timeSheet, timeSheet,
stoppages, stoppages,
notes: notes || null notes: finalNotes || null
} }
}); });
// Create ShiftWorker records for each selected worker
if (workersList.length > 0) {
await prisma.shiftWorker.createMany({
data: workersList.map((workerId: number) => ({
reportId: report.id,
workerId: workerId
}))
});
}
// Manage sheet creation/update // Manage sheet creation/update
await manageSheet( await manageSheet(
report.id, report.id,
@ -156,12 +214,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}; };
export default function NewReport() { 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 actionData = useActionData<typeof action>();
const navigation = useNavigation(); const navigation = useNavigation();
// Form state to preserve values across steps // Form state to preserve values across steps
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
createdDate: new Date().toISOString().split('T')[0], // Default to today
shift: '', shift: '',
areaId: '', areaId: '',
dredgerLocationId: '', dredgerLocationId: '',
@ -182,6 +241,12 @@ export default function NewReport() {
notes: '' notes: ''
}); });
const [isLaborerManuallyEdited, setIsLaborerManuallyEdited] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [selectedWorkers, setSelectedWorkers] = useState<number[]>([]);
const [workerSearchTerm, setWorkerSearchTerm] = useState('');
// Dynamic arrays state // Dynamic arrays state
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{ const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
id: string, id: string,
@ -206,6 +271,7 @@ export default function NewReport() {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const totalSteps = 4; const totalSteps = 4;
const [showZeroEquipmentConfirm, setShowZeroEquipmentConfirm] = useState(false);
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === "submitting";
@ -225,6 +291,18 @@ export default function NewReport() {
return false; return false;
} }
// Validate reclamation stoppages have notes
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;
}
// console.log("Allowing form submission"); // console.log("Allowing form submission");
// console.log("Form being submitted with data:", formData); // console.log("Form being submitted with data:", formData);
// console.log("Time sheet entries:", timeSheetEntries); // console.log("Time sheet entries:", timeSheetEntries);
@ -334,6 +412,89 @@ 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 };
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++;
}
}
}
});
// Update form data with calculated counts
setFormData(prev => ({
...prev,
statsDozers: counts.dozers.toString(),
statsExc: counts.excavators.toString(),
statsLoaders: counts.loaders.toString()
}));
}, [timeSheetEntries, equipment]);
// Stoppage management // Stoppage management
const addStoppageEntry = () => { const addStoppageEntry = () => {
const newEntry = { const newEntry = {
@ -341,8 +502,8 @@ export default function NewReport() {
from: '', from: '',
to: '', to: '',
total: '00:00', total: '00:00',
reason: '', reason: '', // Will be set to 'none' for reclamation by default
responsible: '', responsible: 'reclamation', // Default to reclamation
note: '' note: ''
}; };
setStoppageEntries([...stoppageEntries, newEntry]); setStoppageEntries([...stoppageEntries, newEntry]);
@ -359,17 +520,64 @@ export default function NewReport() {
if (['from', 'to'].includes(field)) { if (['from', 'to'].includes(field)) {
updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to); updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to);
} }
// Handle responsible party change
if (field === 'responsible') {
if (value === 'reclamation') {
updatedEntry.reason = ''; // Set to empty for reclamation (will show as "None")
}
// If changing to dredger, keep current reason or allow user to select
}
return updatedEntry; return updatedEntry;
} }
return entry; return entry;
})); }));
}; };
const nextStep = (event?: React.MouseEvent<HTMLButtonElement>) => { const nextStep = async (event?: React.MouseEvent<HTMLButtonElement>) => {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
// Check for duplicate report on step 1
if (currentStep === 1) {
try {
const response = await fetch('/api/check-duplicate-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
createdDate: formData.createdDate,
shift: formData.shift,
areaId: formData.areaId,
dredgerLocationId: formData.dredgerLocationId,
reclamationLocationId: formData.reclamationLocationId
})
});
const data = await response.json();
if (data.exists) {
setValidationError(`A report already exists for ${formData.shift} shift in this area, dredger location, and reclamation location on ${new Date(formData.createdDate).toLocaleDateString()}`);
return;
}
setValidationError(null);
} catch (error) {
console.error('Error checking for duplicate:', error);
setValidationError('Failed to validate report. Please try again.');
return;
}
}
// Check if we're on step 3 (Equipment & Time Sheet) and have zero equipment
if (currentStep === 3) {
const totalEquipment = parseInt(formData.statsDozers) + parseInt(formData.statsExc) + parseInt(formData.statsLoaders);
if (totalEquipment === 0) {
setShowZeroEquipmentConfirm(true);
return;
}
}
// console.log("Next step clicked, current step:", currentStep); // console.log("Next step clicked, current step:", currentStep);
if (currentStep < totalSteps) { if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
@ -377,6 +585,17 @@ export default function NewReport() {
} }
}; };
const confirmZeroEquipmentAndProceed = () => {
setShowZeroEquipmentConfirm(false);
if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1);
}
};
const cancelZeroEquipmentConfirm = () => {
setShowZeroEquipmentConfirm(false);
};
const prevStep = () => { const prevStep = () => {
if (currentStep > 1) { if (currentStep > 1) {
setCurrentStep(currentStep - 1); setCurrentStep(currentStep - 1);
@ -393,6 +612,61 @@ export default function NewReport() {
} }
}; };
// Validation functions for each step
const isStep1Valid = () => {
return formData.createdDate &&
formData.shift &&
formData.areaId &&
formData.dredgerLocationId &&
formData.reclamationLocationId;
};
const isStep2Valid = () => {
return formData.dredgerLineLength &&
!isNaN(parseInt(formData.dredgerLineLength)) &&
formData.shoreConnection &&
!isNaN(parseInt(formData.shoreConnection));
};
const isStep3Valid = () => {
// Step 3 has no required fields - equipment and time sheet are optional
return true;
};
const isCurrentStepValid = () => {
switch (currentStep) {
case 1: return isStep1Valid();
case 2: return isStep2Valid();
case 3: return isStep3Valid();
case 4: return true; // Step 4 has no required fields
default: return false;
}
};
// Auto-update laborer count when workers change (only if not manually edited)
useEffect(() => {
if (!isLaborerManuallyEdited) {
setFormData(prev => ({
...prev,
statsLaborer: selectedWorkers.length.toString()
}));
}
}, [selectedWorkers, isLaborerManuallyEdited]);
// Worker selection functions
const toggleWorker = (workerId: number) => {
setSelectedWorkers(prev =>
prev.includes(workerId)
? prev.filter(id => id !== workerId)
: [...prev, workerId]
);
};
const filteredWorkers = workers.filter(worker =>
worker.name.toLowerCase().includes(workerSearchTerm.toLowerCase()) &&
!selectedWorkers.includes(worker.id)
);
return ( return (
<DashboardLayout user={user}> <DashboardLayout user={user}>
<div className="max-w-full mx-auto"> <div className="max-w-full mx-auto">
@ -451,7 +725,36 @@ export default function NewReport() {
{/* Step 1: Basic Information */} {/* Step 1: Basic Information */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
{validationError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<svg className="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="ml-3">
<p className="text-sm text-red-800">{validationError}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<label htmlFor="createdDate" className="block text-sm font-medium text-gray-700 mb-2">
Report Date <span className="text-red-500">*</span>
</label>
<input
type="date"
id="createdDate"
name="createdDate"
required
value={formData.createdDate}
onChange={(e) => {
updateFormData('createdDate', e.target.value);
setValidationError(null);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div> <div>
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
Shift <span className="text-red-500">*</span> Shift <span className="text-red-500">*</span>
@ -522,6 +825,47 @@ export default function NewReport() {
)} )}
</div> </div>
<div>
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Reclamation Location <span className="text-red-500">*</span>
</label>
<select
id="reclamationLocationId"
name="reclamationLocationId"
required
value={formData.reclamationLocationId}
onChange={(e) => updateFormData('reclamationLocationId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Select reclamation location</option>
{reclamationLocations.map((location) => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
{actionData?.errors?.reclamationLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
)}
</div>
</div>
</div>
)}
{/* Step 2: Location & Pipeline Details */}
{currentStep === 2 && (
<div className="space-y-4 sm:space-y-6">
{/* Info message about carry-forward */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<svg className="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="ml-3">
<p className="text-sm text-blue-800">
Values below are automatically filled from the last report for this location combination. You can modify them as needed.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
Dredger Line Length (m) <span className="text-red-500">*</span> Dredger Line Length (m) <span className="text-red-500">*</span>
@ -541,27 +885,6 @@ export default function NewReport() {
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p> <p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
)} )}
</div> </div>
</div>
</div>
)}
{/* Step 2: Location & Pipeline Details */}
{currentStep === 2 && (
<div className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Reclamation Location <span className="text-red-500">*</span>
</label>
<select id="reclamationLocationId" name="reclamationLocationId" required value={formData.reclamationLocationId} onChange={(e) => updateFormData('reclamationLocationId', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Select reclamation location</option>
{reclamationLocations.map((location) => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
{actionData?.errors?.reclamationLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
)}
</div>
<div> <div>
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">Shore Connection <span className="text-red-500">*</span></label> <label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">Shore Connection <span className="text-red-500">*</span></label>
<input type="number" id="shoreConnection" name="shoreConnection" required min="0" value={formData.shoreConnection} onChange={(e) => updateFormData('shoreConnection', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter connection length" /> <input type="number" id="shoreConnection" name="shoreConnection" required min="0" value={formData.shoreConnection} onChange={(e) => updateFormData('shoreConnection', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter connection length" />
@ -594,11 +917,69 @@ export default function NewReport() {
<div> <div>
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3> <h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div><label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers</label><input type="number" id="statsDozers" name="statsDozers" min="0" value={formData.statsDozers} onChange={(e) => updateFormData('statsDozers', 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="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsDozers" name="statsDozers" min="0" value={formData.statsDozers} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
<div><label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">Excavators</label><input type="number" id="statsExc" name="statsExc" min="0" value={formData.statsExc} onChange={(e) => updateFormData('statsExc', 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="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</label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} onChange={(e) => updateFormData('statsLoaders', 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="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">Loaders <span className="text-xs text-gray-500">(Auto-calculated)</span></label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-700 cursor-not-allowed" title="Auto-calculated based on time sheet entries" /></div>
<div><label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">Foreman</label><select id="statsForeman" name="statsForeman" value={formData.statsForeman} onChange={(e) => updateFormData('statsForeman', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select foreman</option>{foremen.map((foreman) => (<option key={foreman.id} value={foreman.name}>{foreman.name}</option>))}</select></div> <div><label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">Foreman</label><select id="statsForeman" name="statsForeman" value={formData.statsForeman} onChange={(e) => updateFormData('statsForeman', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select foreman</option>{foremen.map((foreman) => (<option key={foreman.id} value={foreman.name}>{foreman.name}</option>))}</select></div>
<div><label htmlFor="statsLaborer" className="block text-sm font-medium text-gray-700 mb-2">Laborers</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> </div>
<div> <div>
@ -653,11 +1034,11 @@ export default function NewReport() {
<div><label className="block text-sm font-medium text-gray-700 mb-1">From</label><input type="time" value={entry.from} onChange={(e) => 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" /></div> <div><label className="block text-sm font-medium text-gray-700 mb-1">From</label><input type="time" value={entry.from} onChange={(e) => 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" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">To</label><input type="time" value={entry.to} onChange={(e) => 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" /></div> <div><label className="block text-sm font-medium text-gray-700 mb-1">To</label><input type="time" value={entry.to} onChange={(e) => 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" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Total</label><input type="text" value={entry.total} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100" /></div> <div><label className="block text-sm font-medium text-gray-700 mb-1">Total</label><input type="text" value={entry.total} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Reason</label><input type="text" value={entry.reason} onChange={(e) => updateStoppageEntry(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="Stoppage reason" /></div> <div><label className="block text-sm font-medium text-gray-700 mb-1">Reason</label><select value={entry.reason} onChange={(e) => updateStoppageEntry(entry.id, 'reason', e.target.value)} disabled={entry.responsible === 'reclamation'} 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' ? 'bg-gray-100 text-gray-500 cursor-not-allowed border-gray-300' : 'border-gray-300'}`}><option value="">None</option><option value="maintenance">Maintenance</option><option value="shift">Shift</option><option value="lubrication">Lubrication</option><option value="check">Check</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Responsible</label><input type="text" value={entry.responsible} onChange={(e) => updateStoppageEntry(entry.id, 'responsible', 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="Responsible party" /></div> <div><label className="block text-sm font-medium text-gray-700 mb-1">Responsible</label><select value={entry.responsible} onChange={(e) => updateStoppageEntry(entry.id, 'responsible', 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="reclamation">Reclamation</option><option value="dredger">Dredger</option></select></div>
<div className="flex items-end"><button type="button" onClick={() => removeStoppageEntry(entry.id)} className="w-full px-3 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Remove</button></div> <div className="flex items-end"><button type="button" onClick={() => removeStoppageEntry(entry.id)} className="w-full px-3 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Remove</button></div>
</div> </div>
<div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-1">Notes</label><input type="text" value={entry.note} onChange={(e) => updateStoppageEntry(entry.id, 'note', 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="Additional notes" /></div> <div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-1">Notes {entry.responsible === 'reclamation' && <span className="text-red-500">*</span>}</label><input type="text" value={entry.note} onChange={(e) => 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'} /></div>
</div> </div>
))} ))}
</div> </div>
@ -694,7 +1075,7 @@ export default function NewReport() {
<div className="flex space-x-3"> <div className="flex space-x-3">
{currentStep < totalSteps ? ( {currentStep < totalSteps ? (
<button type="button" onClick={(e) => nextStep(e)} className="flex-1 sm:flex-none inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <button type="button" onClick={(e) => nextStep(e)} disabled={!isCurrentStepValid()} className={`flex-1 sm:flex-none inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${!isCurrentStepValid() ? 'text-gray-400 bg-gray-300 cursor-not-allowed' : 'text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'}`}>
Next<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg> Next<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button> </button>
) : ( ) : (
@ -719,19 +1100,21 @@ export default function NewReport() {
{/* Hidden inputs for dynamic data */} {/* Hidden inputs for dynamic data */}
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} /> <input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} /> <input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
<input type="hidden" name="workersList" value={JSON.stringify(selectedWorkers)} />
{/* Hidden inputs for form data from all steps */} {/* Hidden inputs for form data from all steps */}
{currentStep !== 1 && ( {currentStep !== 1 && (
<> <>
<input type="hidden" name="createdDate" value={formData.createdDate} />
<input type="hidden" name="shift" value={formData.shift} /> <input type="hidden" name="shift" value={formData.shift} />
<input type="hidden" name="areaId" value={formData.areaId} /> <input type="hidden" name="areaId" value={formData.areaId} />
<input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} /> <input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} />
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} /> <input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
</> </>
)} )}
{currentStep !== 2 && ( {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="shoreConnection" value={formData.shoreConnection} />
<input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} /> <input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} />
<input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} /> <input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} />
@ -754,6 +1137,44 @@ export default function NewReport() {
<input type="hidden" name="notes" value={formData.notes} /> <input type="hidden" name="notes" value={formData.notes} />
)} )}
</Form> </Form>
{/* Zero Equipment Confirmation Modal */}
{showZeroEquipmentConfirm && (
<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-yellow-100">
<svg className="h-6 w-6 text-yellow-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">No Equipment Added</h3>
<div className="mt-2 px-7 py-3">
<p className="text-sm text-gray-500">
You haven't added any equipment to the time sheet. This means all equipment counts (Dozers, Excavators, Loaders) will be 0.
</p>
<p className="text-sm text-gray-500 mt-2">
Are you sure you want to continue without any equipment entries?
</p>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={cancelZeroEquipmentConfirm}
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"
>
Go Back
</button>
<button
onClick={confirmZeroEquipmentAndProceed}
className="flex-1 px-4 py-2 bg-yellow-600 text-white text-base font-medium rounded-md shadow-sm hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-300"
>
Continue Anyway
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
</DashboardLayout> </DashboardLayout>
); );

View File

@ -3,7 +3,7 @@ import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import { createUserSession, getUserId, verifyLogin } from "~/utils/auth.server"; import { createUserSession, getUserId, verifyLogin } from "~/utils/auth.server";
export const meta: MetaFunction = () => [{ title: "Sign In - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Sign In - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request); const userId = await getUserId(request);

View File

@ -4,7 +4,7 @@ import { Form, Link, useActionData } from "@remix-run/react";
import { createUser, createUserSession, getUserId } from "~/utils/auth.server"; import { createUser, createUserSession, getUserId } from "~/utils/auth.server";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Sign Up - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Sign Up - Alhaffer Report System" }];
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request); const userId = await getUserId(request);

View File

@ -6,7 +6,7 @@ import DashboardLayout from "~/components/DashboardLayout";
import { useState } from "react"; import { useState } from "react";
import { prisma } from "~/utils/db.server"; import { prisma } from "~/utils/db.server";
export const meta: MetaFunction = () => [{ title: "Stoppages Analysis - Phosphat Report" }]; export const meta: MetaFunction = () => [{ title: "Stoppages Analysis - Alhaffer Report System" }];
interface StoppageEntry { interface StoppageEntry {
id: string; id: string;

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

75
compose.yml Normal file
View File

@ -0,0 +1,75 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: phosphat-report-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- 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_SECURE=false
- MAIL_USERNAME=your-email@gmail.com
- MAIL_PASSWORD=your-app-password
- MAIL_FROM_NAME=Phosphat Report System
- MAIL_FROM_EMAIL=noreply@yourcompany.com
- ENCRYPTION_KEY=phosphat-report-default-key-32b
volumes:
- app_data:/app/data
- app_logs:/app/logs
networks:
- app_network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
backup:
image: alpine:latest
container_name: phosphat-report-backup
restart: unless-stopped
volumes:
- app_data:/data:ro
- backup_data:/backup
command:
- sh
- -c
- |
apk add --no-cache dcron sqlite &&
echo '0 2 * * * cp /data/production.db /backup/production_$$(date +%Y%m%d_%H%M%S).db && find /backup -name "production_*.db" -mtime +7 -delete' | crontab - &&
crond -f
networks:
- app_network
depends_on:
- app
volumes:
app_data:
driver: local
app_logs:
driver: local
backup_data:
driver: local
networks:
app_network:
driver: bridge

89
package-lock.json generated
View File

@ -1517,10 +1517,11 @@
} }
}, },
"node_modules/@remix-run/dev": { "node_modules/@remix-run/dev": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.17.1.tgz",
"integrity": "sha512-2EKByaD5CDwh7H56UFVCqc90kCZ9LukPlSwkcsR3gj7WlfL7sXtcIqIopcToAlKAeao3HDbhBlBT2CTOivxZCg==", "integrity": "sha512-Ou9iIewCs4IIoC5FjYBsfNzcCfdrc+3V8thRjULVMvTDfFxRoL+uNz/AlD3jC7Vm8Q08Iryy0joCOh8oghIhvQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.21.8", "@babel/core": "^7.21.8",
"@babel/generator": "^7.21.5", "@babel/generator": "^7.21.5",
@ -1532,9 +1533,9 @@
"@babel/types": "^7.22.5", "@babel/types": "^7.22.5",
"@mdx-js/mdx": "^2.3.0", "@mdx-js/mdx": "^2.3.0",
"@npmcli/package-json": "^4.0.1", "@npmcli/package-json": "^4.0.1",
"@remix-run/node": "2.16.8", "@remix-run/node": "2.17.1",
"@remix-run/router": "1.23.0", "@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", "@types/mdx": "^2.0.5",
"@vanilla-extract/integration": "^6.2.0", "@vanilla-extract/integration": "^6.2.0",
"arg": "^5.0.1", "arg": "^5.0.1",
@ -1586,8 +1587,8 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@remix-run/react": "^2.16.8", "@remix-run/react": "^2.17.0",
"@remix-run/serve": "^2.16.8", "@remix-run/serve": "^2.17.0",
"typescript": "^5.1.0", "typescript": "^5.1.0",
"vite": "^5.1.0 || ^6.0.0", "vite": "^5.1.0 || ^6.0.0",
"wrangler": "^3.28.2" "wrangler": "^3.28.2"
@ -1608,11 +1609,12 @@
} }
}, },
"node_modules/@remix-run/express": { "node_modules/@remix-run/express": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.17.1.tgz",
"integrity": "sha512-NNTosiAJ4jZCRDfWSjV+3Fyu7KoHPeEHruLZEPRNDuXO6Nm5EkRvIkMwdfwyJ+ajE5IPotu8MFtPyNtm3sw/gw==", "integrity": "sha512-qsjfpj2rUwF5jN0XmECpPSgPKWAXVzM4rV1mLgomIrjJISHfzxfNYd9m2/qhyueOZY07tcaUK0LXkjAEvrdMpA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/node": "2.16.8" "@remix-run/node": "2.17.1"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@ -1628,11 +1630,12 @@
} }
}, },
"node_modules/@remix-run/node": { "node_modules/@remix-run/node": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.17.1.tgz",
"integrity": "sha512-foeYXU3mdaBJZnbtGbM8mNdHowz2+QnVGDRo7P3zgFkmsccMEflArGZNbkACGKd9xwDguTxxMJ6cuXBC4jIfgQ==", "integrity": "sha512-pHmHTuLE1Lwazulx3gjrHobgBCsa+Xiq8WUO0ruLeDfEw2DU0c0SNSiyNkugu3rIZautroBwRaOoy7CWJL9xhQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/server-runtime": "2.16.8", "@remix-run/server-runtime": "2.17.1",
"@remix-run/web-fetch": "^4.4.2", "@remix-run/web-fetch": "^4.4.2",
"@web3-storage/multipart-parser": "^1.0.0", "@web3-storage/multipart-parser": "^1.0.0",
"cookie-signature": "^1.1.0", "cookie-signature": "^1.1.0",
@ -1653,12 +1656,13 @@
} }
}, },
"node_modules/@remix-run/react": { "node_modules/@remix-run/react": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.17.1.tgz",
"integrity": "sha512-JmoBUnEu/nPLkU6NGNIG7rfLM97gPpr1LYRJeV680hChr0/2UpfQQwcRLtHz03w1Gz1i/xONAAVOvRHVcXkRlA==", "integrity": "sha512-5MqRK2Z5gkQMDqGfjXSACf/HzvOA+5ug9kiSqaPpK9NX0OF4NlS+cAPKXQWuzc2iLSp6r1RGu8FU1jpZbhsaug==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.0", "@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": "6.30.0",
"react-router-dom": "6.30.0", "react-router-dom": "6.30.0",
"turbo-stream": "2.4.1" "turbo-stream": "2.4.1"
@ -1686,17 +1690,18 @@
} }
}, },
"node_modules/@remix-run/serve": { "node_modules/@remix-run/serve": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.17.1.tgz",
"integrity": "sha512-4exyeXCZoc/Vo8Zc+6Eyao3ONwOyNOK3Yeb0LLkWXd4aeFQ4v59i5fq/j/E+68UnpD/UZQl1Bj0k2hQnGQZhlQ==", "integrity": "sha512-7ep8k31c7z7sNoQRhPBRF4wsSxdbZ7FE11Hi8bQjcW6hK/rQnuHM+cGMv8w9qGjzsYilZeukaHHp0XNtxS4DEQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/express": "2.16.8", "@remix-run/express": "2.17.1",
"@remix-run/node": "2.16.8", "@remix-run/node": "2.17.1",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"compression": "^1.7.4", "compression": "^1.8.1",
"express": "^4.20.0", "express": "^4.20.0",
"get-port": "5.1.1", "get-port": "5.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.1",
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
}, },
"bin": { "bin": {
@ -1707,9 +1712,10 @@
} }
}, },
"node_modules/@remix-run/server-runtime": { "node_modules/@remix-run/server-runtime": {
"version": "2.16.8", "version": "2.17.1",
"resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.16.8.tgz", "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.17.1.tgz",
"integrity": "sha512-ZwWOam4GAQTx10t+wK09YuYctd2Koz5Xy/klDgUN3lmTXmwbV0tZU0baiXEqZXrvyD+WDZ4b0ADDW9Df3+dpzA==", "integrity": "sha512-d1Vp9FxX4KafB111vP2E5C1fmWzPI+gHZ674L1drq+N8Bp9U6FBspi7GAZSU5K5Kxa4T6UF+aE1gK6pVi9R8sw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.0", "@remix-run/router": "1.23.0",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
@ -2108,7 +2114,8 @@
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
}, },
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
@ -3123,10 +3130,11 @@
} }
}, },
"node_modules/@vanilla-extract/integration/node_modules/vite": { "node_modules/@vanilla-extract/integration/node_modules/vite": {
"version": "5.4.19", "version": "5.4.20",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@ -4480,6 +4488,7 @@
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@ -11906,9 +11915,10 @@
} }
}, },
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.3", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.14" "node": ">=14.14"
} }
@ -13097,10 +13107,11 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "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

@ -35,6 +35,9 @@ model Report {
// Sheet relations // Sheet relations
daySheetFor Sheet[] @relation("DayShift") daySheetFor Sheet[] @relation("DayShift")
nightSheetFor Sheet[] @relation("NightShift") nightSheetFor Sheet[] @relation("NightShift")
// Worker relations
shiftWorkers ShiftWorker[]
} }
model Area { model Area {
@ -93,6 +96,25 @@ model Equipment {
number Int number Int
} }
model Worker {
id Int @id @default(autoincrement())
name String @unique
status String @default("active") // 'active' or 'inactive'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shiftWorkers ShiftWorker[]
}
model ShiftWorker {
id Int @id @default(autoincrement())
reportId Int
workerId Int
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
worker Worker @relation(fields: [workerId], references: [id], onDelete: Cascade)
@@unique([reportId, workerId])
}
model Sheet { model Sheet {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
dayShiftId Int? dayShiftId Int?

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('✅ Database seeded successfully!')
console.log(`Created ${areas.length} areas`) console.log(`Created ${areas.length} areas`)
console.log(`Created ${dredgerLocations.length} dredger locations`) console.log(`Created ${dredgerLocations.length} dredger locations`)
@ -147,6 +176,7 @@ async function main() {
console.log(`Created 1 employee`) console.log(`Created 1 employee`)
console.log(`Created 1 foreman`) console.log(`Created 1 foreman`)
console.log(`Created ${equipment.length} equipment records`) console.log(`Created ${equipment.length} equipment records`)
console.log(`Created ${workers.length} workers`)
} }
main() main()

27
start.sh Normal file
View File

@ -0,0 +1,27 @@
#!/bin/sh
set -e
echo "Starting Phosphat Report Application..."
# Run database migrations
echo "Running database migrations..."
npx prisma db push --accept-data-loss
# Run seed using production script
echo "Seeding database..."
if [ -f "scripts/seed-production.js" ]; then
echo "Using production seed script..."
node scripts/seed-production.js
else
echo "Production seed script not found, trying alternative methods..."
if [ -f "prisma/seed.js" ]; then
echo "Using JavaScript seed file..."
node prisma/seed.js
else
echo "No seeding method available, skipping..."
fi
fi
echo "Database setup complete. Starting application on port 3000..."
export PORT=3000
exec npx remix-serve ./build/server/index.js