Compare commits
8 Commits
v2.0-featu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a79ff3e729 | |||
| 367a4c9734 | |||
| cb0960299d | |||
| b1472a7f72 | |||
| 377863b595 | |||
| e3987c2fe6 | |||
| 812e668e17 | |||
| 3fbc0a2093 |
227
CARRY_FORWARD_FEATURE.md
Normal file
227
CARRY_FORWARD_FEATURE.md
Normal 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."
|
||||||
65
CARRY_FORWARD_IMPLEMENTATION.md
Normal file
65
CARRY_FORWARD_IMPLEMENTATION.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Carry Forward Feature - Implementation Complete
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The carry-forward feature automatically populates form fields in the new report form with calculated values from the last report for the same location combination (Area, Dredger Location, and Reclamation Location).
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### API Endpoint
|
||||||
|
**File:** `app/routes/api.last-report-data.ts`
|
||||||
|
|
||||||
|
Fetches the most recent report for a given location combination and calculates carry-forward values:
|
||||||
|
|
||||||
|
- **Dredger Line Length**: Same as last report
|
||||||
|
- **Shore Connection**: Same as last report
|
||||||
|
- **Reclamation Height Base**: (Base Height + Extra Height) from last report
|
||||||
|
- **Pipeline Main**: (Main + Extension 1) from last report
|
||||||
|
- **Pipeline Reserve**: (Reserve + Extension 2) from last report
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
**File:** `app/routes/reports_.new.tsx`
|
||||||
|
|
||||||
|
#### useEffect Hook
|
||||||
|
Added a `useEffect` that triggers when:
|
||||||
|
- User reaches Step 2
|
||||||
|
- All three location fields are selected (Area, Dredger Location, Reclamation Location)
|
||||||
|
|
||||||
|
The hook:
|
||||||
|
1. Fetches last report data from the API
|
||||||
|
2. Only updates fields that are still at their default values
|
||||||
|
3. Preserves any user-modified values
|
||||||
|
|
||||||
|
#### User Experience
|
||||||
|
- Info message displayed in Step 2 explaining that values are auto-filled
|
||||||
|
- Users can modify any auto-filled values
|
||||||
|
- Only applies to fields that haven't been manually changed
|
||||||
|
- Gracefully handles cases where no previous report exists
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### When Values Are Carried Forward
|
||||||
|
- User selects Area, Dredger Location, and Dredger Line Length in Step 1
|
||||||
|
- User moves to Step 2 and selects Reclamation Location
|
||||||
|
- System automatically fetches and populates:
|
||||||
|
- Dredger Line Length (if not already entered)
|
||||||
|
- Shore Connection (if empty)
|
||||||
|
- Reclamation Height Base (if still at default 0)
|
||||||
|
- Pipeline Main (if still at default 0)
|
||||||
|
- Pipeline Reserve (if still at default 0)
|
||||||
|
|
||||||
|
### When Values Are NOT Carried Forward
|
||||||
|
- No previous report exists for the location combination
|
||||||
|
- User has already manually entered values
|
||||||
|
- API request fails (fails silently, doesn't block user)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. **Efficiency**: Reduces data entry time for recurring locations
|
||||||
|
2. **Accuracy**: Ensures continuity of measurements across shifts
|
||||||
|
3. **User-Friendly**: Non-intrusive - users can still override any value
|
||||||
|
4. **Smart**: Only updates fields that haven't been touched by the user
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
- Uses GET request to `/api/last-report-data` with query parameters
|
||||||
|
- Calculations performed server-side for data integrity
|
||||||
|
- Client-side state management prevents overwriting user input
|
||||||
|
- Error handling ensures feature doesn't break form functionality
|
||||||
35
Dockerfile
35
Dockerfile
@ -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"]
|
||||||
70
EDIT_REPORT_IMPLEMENTATION.md
Normal file
70
EDIT_REPORT_IMPLEMENTATION.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Edit Report Implementation Summary
|
||||||
|
|
||||||
|
## What Was Done
|
||||||
|
|
||||||
|
I've started creating a dedicated edit route at `app/routes/reports_.$id.edit.tsx` that mirrors the structure of `reports_.new.tsx` but with the following key differences:
|
||||||
|
|
||||||
|
### Locked Fields (Cannot be edited):
|
||||||
|
1. **Report Date** - Displayed as read-only
|
||||||
|
2. **Shift** - Displayed as read-only badge
|
||||||
|
3. **Area** - Displayed as read-only
|
||||||
|
4. **Dredger Location** - Displayed as read-only
|
||||||
|
5. **Reclamation Location** - Displayed as read-only
|
||||||
|
|
||||||
|
### Editable Fields (3 Steps):
|
||||||
|
|
||||||
|
**Step 1: Pipeline Details**
|
||||||
|
- Dredger Line Length
|
||||||
|
- Shore Connection
|
||||||
|
- Reclamation Height (Base & Extra)
|
||||||
|
- Pipeline Length (Main, Ext1, Reserve, Ext2)
|
||||||
|
|
||||||
|
**Step 2: Equipment & Time Sheet**
|
||||||
|
- Equipment Statistics (Auto-calculated from timesheet)
|
||||||
|
- Foreman selection
|
||||||
|
- Workers selection (with autocomplete)
|
||||||
|
- Time Sheet entries
|
||||||
|
|
||||||
|
**Step 3: Stoppages & Notes**
|
||||||
|
- Stoppage entries
|
||||||
|
- Additional notes
|
||||||
|
|
||||||
|
## Next Steps Required
|
||||||
|
|
||||||
|
Due to file size limitations, the implementation needs to be completed with:
|
||||||
|
|
||||||
|
1. **Helper Functions** (same as new report):
|
||||||
|
- `calculateTimeDifference`
|
||||||
|
- `calculateStoppageTime`
|
||||||
|
- Time sheet management functions
|
||||||
|
- Stoppage management functions
|
||||||
|
- Worker selection functions
|
||||||
|
|
||||||
|
2. **JSX Render** with 3 steps showing:
|
||||||
|
- Locked fields display at the top
|
||||||
|
- Step 1: Pipeline details form
|
||||||
|
- Step 2: Equipment & timesheet
|
||||||
|
- Step 3: Stoppages & notes
|
||||||
|
- Navigation buttons
|
||||||
|
|
||||||
|
3. **Update reports.tsx** to change Edit button:
|
||||||
|
```tsx
|
||||||
|
<Link
|
||||||
|
to={`/reports/${report.id}/edit`}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
- Route: `/reports/:id/edit`
|
||||||
|
- File: `app/routes/reports_.$id.edit.tsx`
|
||||||
|
- Uses same multi-step wizard as new report
|
||||||
|
- Preserves all existing data
|
||||||
|
- Updates workers via ShiftWorker table
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
- Regular users (level 1): Can only edit their latest report
|
||||||
|
- Supervisors/Admins (level 2+): Can edit any report
|
||||||
|
- Cannot change core identifying fields (date, shift, locations)
|
||||||
405
EDIT_ROUTE_COMPLETE_GUIDE.md
Normal file
405
EDIT_ROUTE_COMPLETE_GUIDE.md
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
# ✅ IMPLEMENTED - Edit Route Complete
|
||||||
|
|
||||||
|
## Implementation Status: COMPLETE
|
||||||
|
|
||||||
|
The edit route has been successfully transformed from the guide into a fully working implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Complete Edit Route Implementation Guide
|
||||||
|
|
||||||
|
## File: `app/routes/reports_.$id.edit.tsx`
|
||||||
|
|
||||||
|
Since the file is too large to modify in one operation, here are ALL the changes needed to transform `reports_.new.tsx` into a working edit route:
|
||||||
|
|
||||||
|
### 1. Update Meta Function
|
||||||
|
```typescript
|
||||||
|
export const meta: MetaFunction = () => [{ title: "Edit Report - Phosphat Report" }];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update Loader Function
|
||||||
|
Replace the entire loader with:
|
||||||
|
```typescript
|
||||||
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
|
const user = await requireAuthLevel(request, 1);
|
||||||
|
const reportId = params.id;
|
||||||
|
|
||||||
|
if (!reportId) {
|
||||||
|
throw new Response("Report ID is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the report to edit
|
||||||
|
const report = await prisma.report.findUnique({
|
||||||
|
where: { id: parseInt(reportId) },
|
||||||
|
include: {
|
||||||
|
employee: { select: { name: true } },
|
||||||
|
area: { select: { name: true } },
|
||||||
|
dredgerLocation: { select: { name: true, class: true } },
|
||||||
|
reclamationLocation: { select: { name: true } },
|
||||||
|
shiftWorkers: {
|
||||||
|
include: {
|
||||||
|
worker: { select: { id: true, name: true, status: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
throw new Response("Report not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (user.authLevel < 2 && report.employeeId !== user.id) {
|
||||||
|
throw new Response("You can only edit your own reports", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.authLevel < 2) {
|
||||||
|
const latestUserReport = await prisma.report.findFirst({
|
||||||
|
where: { employeeId: user.id },
|
||||||
|
orderBy: { createdDate: 'desc' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestUserReport || latestUserReport.id !== parseInt(reportId)) {
|
||||||
|
throw new Response("You can only edit your latest report", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dropdown data for form
|
||||||
|
const [areas, dredgerLocations, reclamationLocations, foremen, equipment, workers] = await Promise.all([
|
||||||
|
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
|
||||||
|
prisma.worker.findMany({ where: { status: 'active' }, orderBy: { name: 'asc' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
user,
|
||||||
|
report,
|
||||||
|
areas,
|
||||||
|
dredgerLocations,
|
||||||
|
reclamationLocations,
|
||||||
|
foremen,
|
||||||
|
equipment,
|
||||||
|
workers
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update Action Function
|
||||||
|
Replace the entire action with:
|
||||||
|
```typescript
|
||||||
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||||
|
const user = await requireAuthLevel(request, 1);
|
||||||
|
const reportId = params.id;
|
||||||
|
|
||||||
|
if (!reportId) {
|
||||||
|
return json({ errors: { form: "Report ID is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReport = await prisma.report.findUnique({
|
||||||
|
where: { id: parseInt(reportId) },
|
||||||
|
select: {
|
||||||
|
employeeId: true,
|
||||||
|
createdDate: true,
|
||||||
|
shift: true,
|
||||||
|
areaId: true,
|
||||||
|
dredgerLocationId: true,
|
||||||
|
reclamationLocationId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingReport) {
|
||||||
|
return json({ errors: { form: "Report not found" } }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.authLevel < 2) {
|
||||||
|
if (existingReport.employeeId !== user.id) {
|
||||||
|
return json({ errors: { form: "You can only edit your own reports" } }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUserReport = await prisma.report.findFirst({
|
||||||
|
where: { employeeId: user.id },
|
||||||
|
orderBy: { createdDate: 'desc' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestUserReport || latestUserReport.id !== parseInt(reportId)) {
|
||||||
|
return json({ errors: { form: "You can only edit your latest report" } }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const dredgerLineLength = formData.get("dredgerLineLength");
|
||||||
|
const shoreConnection = formData.get("shoreConnection");
|
||||||
|
const notes = formData.get("notes");
|
||||||
|
const reclamationHeightBase = formData.get("reclamationHeightBase");
|
||||||
|
const reclamationHeightExtra = formData.get("reclamationHeightExtra");
|
||||||
|
const pipelineMain = formData.get("pipelineMain");
|
||||||
|
const pipelineExt1 = formData.get("pipelineExt1");
|
||||||
|
const pipelineReserve = formData.get("pipelineReserve");
|
||||||
|
const pipelineExt2 = formData.get("pipelineExt2");
|
||||||
|
const statsDozers = formData.get("statsDozers");
|
||||||
|
const statsExc = formData.get("statsExc");
|
||||||
|
const statsLoaders = formData.get("statsLoaders");
|
||||||
|
const statsForeman = formData.get("statsForeman");
|
||||||
|
const statsLaborer = formData.get("statsLaborer");
|
||||||
|
const workersListData = formData.get("workersList");
|
||||||
|
const timeSheetData = formData.get("timeSheetData");
|
||||||
|
const stoppagesData = formData.get("stoppagesData");
|
||||||
|
|
||||||
|
if (typeof dredgerLineLength !== "string" || isNaN(parseInt(dredgerLineLength))) {
|
||||||
|
return json({ errors: { dredgerLineLength: "Valid dredger line length is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof shoreConnection !== "string" || isNaN(parseInt(shoreConnection))) {
|
||||||
|
return json({ errors: { shoreConnection: "Valid shore connection is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let timeSheet = [];
|
||||||
|
let stoppages = [];
|
||||||
|
let workersList = [];
|
||||||
|
|
||||||
|
if (timeSheetData && typeof timeSheetData === "string") {
|
||||||
|
try { timeSheet = JSON.parse(timeSheetData); } catch (e) { timeSheet = []; }
|
||||||
|
}
|
||||||
|
if (stoppagesData && typeof stoppagesData === "string") {
|
||||||
|
try { stoppages = JSON.parse(stoppagesData); } catch (e) { stoppages = []; }
|
||||||
|
}
|
||||||
|
if (workersListData && typeof workersListData === "string") {
|
||||||
|
try { workersList = JSON.parse(workersListData); } catch (e) { workersList = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext1Value = parseInt(pipelineExt1 as string) || 0;
|
||||||
|
const ext2Value = parseInt(pipelineExt2 as string) || 0;
|
||||||
|
const shiftText = existingReport.shift === 'day' ? 'Day' : 'Night';
|
||||||
|
|
||||||
|
let automaticNotes = [];
|
||||||
|
if (ext1Value > 0) automaticNotes.push(`Main Extension ${ext1Value}m ${shiftText}`);
|
||||||
|
if (ext2Value > 0) automaticNotes.push(`Reserve Extension ${ext2Value}m ${shiftText}`);
|
||||||
|
|
||||||
|
let finalNotes = notes || '';
|
||||||
|
if (automaticNotes.length > 0) {
|
||||||
|
const automaticNotesText = automaticNotes.join(', ');
|
||||||
|
finalNotes = finalNotes.trim() ? `${automaticNotesText}. ${finalNotes}` : automaticNotesText;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.report.update({
|
||||||
|
where: { id: parseInt(reportId) },
|
||||||
|
data: {
|
||||||
|
dredgerLineLength: parseInt(dredgerLineLength),
|
||||||
|
shoreConnection: parseInt(shoreConnection),
|
||||||
|
reclamationHeight: {
|
||||||
|
base: parseInt(reclamationHeightBase as string) || 0,
|
||||||
|
extra: parseInt(reclamationHeightExtra as string) || 0
|
||||||
|
},
|
||||||
|
pipelineLength: {
|
||||||
|
main: parseInt(pipelineMain as string) || 0,
|
||||||
|
ext1: ext1Value,
|
||||||
|
reserve: parseInt(pipelineReserve as string) || 0,
|
||||||
|
ext2: ext2Value
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
Dozers: parseInt(statsDozers as string) || 0,
|
||||||
|
Exc: parseInt(statsExc as string) || 0,
|
||||||
|
Loaders: parseInt(statsLoaders as string) || 0,
|
||||||
|
Foreman: statsForeman as string || "",
|
||||||
|
Laborer: workersList.length
|
||||||
|
},
|
||||||
|
timeSheet,
|
||||||
|
stoppages,
|
||||||
|
notes: finalNotes || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update workers
|
||||||
|
await prisma.shiftWorker.deleteMany({ where: { reportId: parseInt(reportId) } });
|
||||||
|
if (workersList.length > 0) {
|
||||||
|
await prisma.shiftWorker.createMany({
|
||||||
|
data: workersList.map((workerId: number) => ({
|
||||||
|
reportId: parseInt(reportId),
|
||||||
|
workerId: workerId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/reports?success=Report updated successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
return json({ errors: { form: "Failed to update report. Please try again." } }, { status: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Component - Change loader destructuring
|
||||||
|
```typescript
|
||||||
|
const { user, report, areas, dredgerLocations, reclamationLocations, foremen, equipment, workers } = useLoaderData<typeof loader>();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Initialize form data with existing report data
|
||||||
|
Replace the formData useState with:
|
||||||
|
```typescript
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
dredgerLineLength: report.dredgerLineLength.toString(),
|
||||||
|
shoreConnection: report.shoreConnection.toString(),
|
||||||
|
reclamationHeightBase: (report.reclamationHeight as any).base?.toString() || '0',
|
||||||
|
reclamationHeightExtra: (report.reclamationHeight as any).extra?.toString() || '0',
|
||||||
|
pipelineMain: (report.pipelineLength as any).main?.toString() || '0',
|
||||||
|
pipelineExt1: (report.pipelineLength as any).ext1?.toString() || '0',
|
||||||
|
pipelineReserve: (report.pipelineLength as any).reserve?.toString() || '0',
|
||||||
|
pipelineExt2: (report.pipelineLength as any).ext2?.toString() || '0',
|
||||||
|
statsDozers: (report.stats as any).Dozers?.toString() || '0',
|
||||||
|
statsExc: (report.stats as any).Exc?.toString() || '0',
|
||||||
|
statsLoaders: (report.stats as any).Loaders?.toString() || '0',
|
||||||
|
statsForeman: (report.stats as any).Foreman || '',
|
||||||
|
statsLaborer: (report.stats as any).Laborer?.toString() || '0',
|
||||||
|
notes: report.notes || ''
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Initialize workers with existing data
|
||||||
|
Replace selectedWorkers useState with:
|
||||||
|
```typescript
|
||||||
|
const [selectedWorkers, setSelectedWorkers] = useState<number[]>(
|
||||||
|
report.shiftWorkers?.map((sw: any) => sw.worker.id) || []
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Initialize timesheet and stoppages with existing data
|
||||||
|
Replace the useState declarations with:
|
||||||
|
```typescript
|
||||||
|
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{...}>>(
|
||||||
|
Array.isArray(report.timeSheet) ? (report.timeSheet as any[]).map((entry: any, index: number) => ({
|
||||||
|
...entry,
|
||||||
|
id: entry.id || `existing-${index}`
|
||||||
|
})) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const [stoppageEntries, setStoppageEntries] = useState<Array<{...}>>(
|
||||||
|
Array.isArray(report.stoppages) ? (report.stoppages as any[]).map((entry: any, index: number) => ({
|
||||||
|
...entry,
|
||||||
|
id: entry.id || `existing-${index}`
|
||||||
|
})) : []
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Change totalSteps to 3 (remove basic info step)
|
||||||
|
```typescript
|
||||||
|
const totalSteps = 3;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Update page title
|
||||||
|
```typescript
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Edit Report</h1>
|
||||||
|
<p className="mt-2 text-sm sm:text-base text-gray-600">Update shift details</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Update back button
|
||||||
|
```typescript
|
||||||
|
<Link to="/reports" className="inline-flex items-center...">
|
||||||
|
Back to Reports
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Replace Step 1 with Locked Fields Display + Pipeline Details
|
||||||
|
```typescript
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Locked Fields Display */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Report Information (Cannot be changed)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Date:</span>
|
||||||
|
<span className="ml-2 text-gray-900">{new Date(report.createdDate).toLocaleDateString('en-GB')}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Shift:</span>
|
||||||
|
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${report.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
|
||||||
|
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Area:</span>
|
||||||
|
<span className="ml-2 text-gray-900">{report.area.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Dredger Location:</span>
|
||||||
|
<span className="ml-2 text-gray-900">{report.dredgerLocation.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="font-medium text-gray-600">Reclamation Location:</span>
|
||||||
|
<span className="ml-2 text-gray-900">{report.reclamationLocation.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable Pipeline Details - Same as Step 2 from new report */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dredger Line Length (m) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="dredgerLineLength"
|
||||||
|
name="dredgerLineLength"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
value={formData.dredgerLineLength}
|
||||||
|
onChange={(e) => updateFormData('dredgerLineLength', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Shore Connection <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="shoreConnection"
|
||||||
|
name="shoreConnection"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
value={formData.shoreConnection}
|
||||||
|
onChange={(e) => updateFormData('shoreConnection', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Add Reclamation Height and Pipeline Length sections here - same as new report Step 2 */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. Update step titles
|
||||||
|
```typescript
|
||||||
|
const getStepTitle = (step: number) => {
|
||||||
|
switch (step) {
|
||||||
|
case 1: return "Pipeline Details";
|
||||||
|
case 2: return "Equipment & Time Sheet";
|
||||||
|
case 3: return "Stoppages & Notes";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13. Update submit button text
|
||||||
|
```typescript
|
||||||
|
<button type="submit" ...>
|
||||||
|
{isSubmitting ? 'Updating Report...' : 'Update Report'}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14. Remove validation error state and duplicate check
|
||||||
|
Remove the `validationError` state and the duplicate check logic from `nextStep` function.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
The edit route is now a 3-step wizard that:
|
||||||
|
- Step 1: Shows locked fields + Pipeline details
|
||||||
|
- Step 2: Equipment & Time Sheet
|
||||||
|
- Step 3: Stoppages & Notes
|
||||||
|
|
||||||
|
All existing data is pre-populated and the user cannot change the core identifying fields (date, shift, locations).
|
||||||
@ -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={
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
42
app/routes/api.check-duplicate-report.ts
Normal file
42
app/routes/api.check-duplicate-report.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { prisma } from "~/utils/db.server";
|
||||||
|
import { requireAuthLevel } from "~/utils/auth.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
await requireAuthLevel(request, 1);
|
||||||
|
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createdDate, shift, areaId, dredgerLocationId, reclamationLocationId } = await request.json();
|
||||||
|
|
||||||
|
// Parse the date and set to start of day
|
||||||
|
const date = new Date(createdDate);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const nextDay = new Date(date);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
|
// Check if a report already exists with same date, shift, area, dredger location, and reclamation location
|
||||||
|
const existingReport = await prisma.report.findFirst({
|
||||||
|
where: {
|
||||||
|
createdDate: {
|
||||||
|
gte: date,
|
||||||
|
lt: nextDay
|
||||||
|
},
|
||||||
|
shift,
|
||||||
|
areaId: parseInt(areaId),
|
||||||
|
dredgerLocationId: parseInt(dredgerLocationId),
|
||||||
|
reclamationLocationId: parseInt(reclamationLocationId)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ exists: !!existingReport });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking for duplicate report:", error);
|
||||||
|
return json({ error: "Failed to check for duplicate" }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
72
app/routes/api.equipment-usage.ts
Normal file
72
app/routes/api.equipment-usage.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { requireAuthLevel } from "~/utils/auth.server";
|
||||||
|
import { prisma } from "~/utils/db.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await requireAuthLevel(request, 2); // Only supervisors and admins
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const category = url.searchParams.get('category');
|
||||||
|
const model = url.searchParams.get('model');
|
||||||
|
const number = url.searchParams.get('number');
|
||||||
|
const dateFrom = url.searchParams.get('dateFrom');
|
||||||
|
const dateTo = url.searchParams.get('dateTo');
|
||||||
|
|
||||||
|
if (!category || !model || !number) {
|
||||||
|
return json({ error: "Equipment details are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build where clause for reports
|
||||||
|
const whereClause: any = {};
|
||||||
|
|
||||||
|
// Add date filters if provided
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
whereClause.createdDate = {};
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause.createdDate.gte = new Date(dateFrom + 'T00:00:00.000Z');
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause.createdDate.lte = new Date(dateTo + 'T23:59:59.999Z');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all reports that match the date filter
|
||||||
|
const reports = await prisma.report.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { createdDate: 'desc' },
|
||||||
|
include: {
|
||||||
|
employee: { select: { name: true } },
|
||||||
|
area: { select: { name: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter reports that have this equipment in their timeSheet
|
||||||
|
// Equipment machine format in timeSheet: "Model (Number)"
|
||||||
|
const equipmentMachine = `${model} (${number})`;
|
||||||
|
const usage: any[] = [];
|
||||||
|
|
||||||
|
reports.forEach((report) => {
|
||||||
|
const timeSheet = report.timeSheet as any[];
|
||||||
|
if (Array.isArray(timeSheet) && timeSheet.length > 0) {
|
||||||
|
timeSheet.forEach((entry: any) => {
|
||||||
|
// Check if the machine matches (trim whitespace)
|
||||||
|
const entryMachine = (entry.machine || '').trim();
|
||||||
|
const searchMachine = equipmentMachine.trim();
|
||||||
|
|
||||||
|
if (entryMachine === searchMachine) {
|
||||||
|
usage.push({
|
||||||
|
createdDate: report.createdDate,
|
||||||
|
shift: report.shift,
|
||||||
|
area: report.area,
|
||||||
|
employee: report.employee,
|
||||||
|
totalHours: entry.total || '00:00',
|
||||||
|
reason: entry.reason || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ usage });
|
||||||
|
};
|
||||||
55
app/routes/api.last-report-data.ts
Normal file
55
app/routes/api.last-report-data.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { prisma } from "~/utils/db.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const areaId = url.searchParams.get("areaId");
|
||||||
|
const dredgerLocationId = url.searchParams.get("dredgerLocationId");
|
||||||
|
const reclamationLocationId = url.searchParams.get("reclamationLocationId");
|
||||||
|
|
||||||
|
if (!areaId || !dredgerLocationId || !reclamationLocationId) {
|
||||||
|
return json({ data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the most recent report for this location combination
|
||||||
|
const lastReport = await prisma.report.findFirst({
|
||||||
|
where: {
|
||||||
|
areaId: parseInt(areaId),
|
||||||
|
dredgerLocationId: parseInt(dredgerLocationId),
|
||||||
|
reclamationLocationId: parseInt(reclamationLocationId),
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdDate: 'desc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
dredgerLineLength: true,
|
||||||
|
shoreConnection: true,
|
||||||
|
reclamationHeight: true,
|
||||||
|
pipelineLength: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastReport) {
|
||||||
|
return json({ data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reclamationHeight = lastReport.reclamationHeight as any;
|
||||||
|
const pipelineLength = lastReport.pipelineLength as any;
|
||||||
|
|
||||||
|
// Calculate carry-forward values
|
||||||
|
const carryForwardData = {
|
||||||
|
dredgerLineLength: lastReport.dredgerLineLength,
|
||||||
|
shoreConnection: lastReport.shoreConnection,
|
||||||
|
reclamationHeightBase: (reclamationHeight.base || 0) + (reclamationHeight.extra || 0),
|
||||||
|
pipelineMain: (pipelineLength.main || 0) + (pipelineLength.ext1 || 0),
|
||||||
|
pipelineReserve: (pipelineLength.reserve || 0) + (pipelineLength.ext2 || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return json({ data: carryForwardData });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching last report data:", error);
|
||||||
|
return json({ data: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
51
app/routes/api.worker-shifts.ts
Normal file
51
app/routes/api.worker-shifts.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { requireAuthLevel } from "~/utils/auth.server";
|
||||||
|
import { prisma } from "~/utils/db.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await requireAuthLevel(request, 2); // Only supervisors and admins
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workerId = url.searchParams.get('workerId');
|
||||||
|
const dateFrom = url.searchParams.get('dateFrom');
|
||||||
|
const dateTo = url.searchParams.get('dateTo');
|
||||||
|
|
||||||
|
if (!workerId) {
|
||||||
|
return json({ error: "Worker ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build where clause for reports
|
||||||
|
const whereClause: any = {
|
||||||
|
shiftWorkers: {
|
||||||
|
some: {
|
||||||
|
workerId: parseInt(workerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add date filters if provided
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
whereClause.createdDate = {};
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause.createdDate.gte = new Date(dateFrom + 'T00:00:00.000Z');
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause.createdDate.lte = new Date(dateTo + 'T23:59:59.999Z');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch shifts where this worker was assigned
|
||||||
|
const shifts = await prisma.report.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { createdDate: 'desc' },
|
||||||
|
include: {
|
||||||
|
employee: { select: { name: true } },
|
||||||
|
area: { select: { name: true } },
|
||||||
|
dredgerLocation: { select: { name: true, class: true } },
|
||||||
|
reclamationLocation: { select: { name: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ shifts });
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1197
app/routes/reports_.$id.edit.tsx
Normal file
1197
app/routes/reports_.$id.edit.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
533
app/routes/workers.tsx
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||||
|
import { json, redirect } from "@remix-run/node";
|
||||||
|
import { Form, useActionData, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
|
||||||
|
import { requireAuthLevel } from "~/utils/auth.server";
|
||||||
|
import DashboardLayout from "~/components/DashboardLayout";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { prisma } from "~/utils/db.server";
|
||||||
|
import Toast from "~/components/Toast";
|
||||||
|
|
||||||
|
export const meta: MetaFunction = () => [{ title: "Workers - Alhaffer Report System" }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const user = await requireAuthLevel(request, 2); // Only supervisors and admins
|
||||||
|
|
||||||
|
const workers = await prisma.worker.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ user, workers });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
await requireAuthLevel(request, 2);
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "create") {
|
||||||
|
const name = formData.get("name");
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name) {
|
||||||
|
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.worker.create({
|
||||||
|
data: { name, status: "active" }
|
||||||
|
});
|
||||||
|
return redirect("/workers?success=Worker created successfully!");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "P2002") {
|
||||||
|
return json({ errors: { name: "A worker with this name already exists" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
return json({ errors: { form: "Failed to create worker" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "update") {
|
||||||
|
const id = formData.get("id");
|
||||||
|
const name = formData.get("name");
|
||||||
|
const status = formData.get("status");
|
||||||
|
|
||||||
|
if (typeof id !== "string" || !id) {
|
||||||
|
return json({ errors: { form: "Invalid worker ID" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof name !== "string" || !name) {
|
||||||
|
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof status !== "string" || !["active", "inactive"].includes(status)) {
|
||||||
|
return json({ errors: { status: "Valid status is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.worker.update({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
data: { name, status }
|
||||||
|
});
|
||||||
|
return redirect("/workers?success=Worker updated successfully!");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "P2002") {
|
||||||
|
return json({ errors: { name: "A worker with this name already exists" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
return json({ errors: { form: "Failed to update worker" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
const id = formData.get("id");
|
||||||
|
|
||||||
|
if (typeof id !== "string" || !id) {
|
||||||
|
return json({ errors: { form: "Invalid worker ID" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.worker.delete({
|
||||||
|
where: { id: parseInt(id) }
|
||||||
|
});
|
||||||
|
return redirect("/workers?success=Worker deleted successfully!");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "P2003") {
|
||||||
|
return redirect("/workers?error=Cannot delete worker: worker is assigned to shifts");
|
||||||
|
}
|
||||||
|
return redirect("/workers?error=Failed to delete worker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ errors: { form: "Invalid action" } }, { status: 400 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Workers() {
|
||||||
|
const { user, workers } = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingWorker, setEditingWorker] = useState<any>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | null>(null);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
|
const [showShiftsModal, setShowShiftsModal] = useState(false);
|
||||||
|
const [selectedWorker, setSelectedWorker] = useState<any>(null);
|
||||||
|
const [workerShifts, setWorkerShifts] = useState<any[]>([]);
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const [isLoadingShifts, setIsLoadingShifts] = useState(false);
|
||||||
|
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
|
// Handle success messages from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
const successMessage = searchParams.get("success");
|
||||||
|
const errorMessage = searchParams.get("error");
|
||||||
|
|
||||||
|
if (successMessage) {
|
||||||
|
setToast({ message: successMessage, type: "success" });
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
// Close modals on success
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingWorker(null);
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
} else if (errorMessage) {
|
||||||
|
setToast({ message: errorMessage, type: "error" });
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
// Close delete confirm modal on error
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
// Handle action errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.errors) {
|
||||||
|
const errors = actionData.errors as any;
|
||||||
|
const errorMessage = errors.form || errors.name || errors.status || "An error occurred";
|
||||||
|
setToast({ message: errorMessage, type: "error" });
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
const handleEdit = (worker: any) => {
|
||||||
|
setEditingWorker(worker);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingWorker(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewShifts = (worker: any) => {
|
||||||
|
setSelectedWorker(worker);
|
||||||
|
setShowShiftsModal(true);
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
setWorkerShifts([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseShiftsModal = () => {
|
||||||
|
setShowShiftsModal(false);
|
||||||
|
setSelectedWorker(null);
|
||||||
|
setWorkerShifts([]);
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterShifts = async () => {
|
||||||
|
if (!selectedWorker) return;
|
||||||
|
|
||||||
|
setIsLoadingShifts(true);
|
||||||
|
try {
|
||||||
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
workerId: selectedWorker.id.toString()
|
||||||
|
});
|
||||||
|
if (dateFrom) params.append('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.append('dateTo', dateTo);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/worker-shifts?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.shifts) {
|
||||||
|
setWorkerShifts(data.shifts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setToast({ message: "Failed to load shifts", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsLoadingShifts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout user={user}>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="mb-6 sm:mb-8">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Workers Management</h1>
|
||||||
|
<p className="mt-2 text-sm sm:text-base text-gray-600">Manage laborers and workers</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Add Worker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow-md rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No workers found. Click "Add Worker" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
workers.map((worker) => (
|
||||||
|
<tr key={worker.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{worker.name}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${worker.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||||
|
{worker.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewShifts(worker)}
|
||||||
|
className="text-teal-600 hover:text-teal-900"
|
||||||
|
>
|
||||||
|
View Shifts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(worker)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(worker.id)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
{editingWorker ? "Edit Worker" : "Add New Worker"}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<input type="hidden" name="intent" value={editingWorker ? "update" : "create"} />
|
||||||
|
{editingWorker && <input type="hidden" name="id" value={editingWorker.id} />}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Worker Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
defaultValue={editingWorker?.name}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
placeholder="Enter worker name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingWorker && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Status <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
name="status"
|
||||||
|
required
|
||||||
|
defaultValue={editingWorker?.status}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Saving..." : editingWorker ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View Shifts Modal */}
|
||||||
|
{showShiftsModal && selectedWorker && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Shifts for {selectedWorker.name}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseShiftsModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Filters */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dateFrom" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
From Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="dateFrom"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dateTo" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
To Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="dateTo"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleFilterShifts}
|
||||||
|
disabled={isLoadingShifts}
|
||||||
|
className="w-full px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingShifts ? 'Loading...' : 'Filter Shifts'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shifts List */}
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{workerShifts.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Shift
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Area
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Dredger Location
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Employee
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{workerShifts.map((shift: any) => (
|
||||||
|
<tr key={shift.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{new Date(shift.createdDate).toLocaleDateString('en-GB')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${shift.shift === 'day' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
|
||||||
|
{shift.shift.charAt(0).toUpperCase() + shift.shift.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{shift.area.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{shift.dredgerLocation.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{shift.employee.name}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
{isLoadingShifts ? 'Loading shifts...' : 'Click "Filter Shifts" to view shifts for this worker'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{workerShifts.length > 0 && `Showing ${workerShifts.length} shift${workerShifts.length !== 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseShiftsModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||||
|
<svg className="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900 mt-4">Delete Worker</h3>
|
||||||
|
<div className="mt-2 px-7 py-3">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Are you sure you want to delete this worker? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(null)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<Form method="post" className="flex-1">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<input type="hidden" name="id" value={showDeleteConfirm} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full px-4 py-2 bg-red-600 text-white text-base font-medium rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-300"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
compose.yml
Normal file
75
compose.yml
Normal 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
89
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
23
prisma/migrations/add_workers_tables/migration.sql
Normal file
23
prisma/migrations/add_workers_tables/migration.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Worker" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShiftWorker" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"reportId" INTEGER NOT NULL,
|
||||||
|
"workerId" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "ShiftWorker_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "ShiftWorker_workerId_fkey" FOREIGN KEY ("workerId") REFERENCES "Worker" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Worker_name_key" ON "Worker"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ShiftWorker_reportId_workerId_key" ON "ShiftWorker"("reportId", "workerId");
|
||||||
@ -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?
|
||||||
|
|||||||
@ -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
27
start.sh
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user