11wdd4wvvv
This commit is contained in:
parent
b9fccefab1
commit
f5228aec9f
109
DUPLICATE_FEATURE_UPDATES.md
Normal file
109
DUPLICATE_FEATURE_UPDATES.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Duplicate Feature Updates
|
||||
|
||||
## Changes Made
|
||||
|
||||
### ✅ **Permission Updates**
|
||||
1. **Universal Access**: All users (auth level 1+) can now duplicate ANY report, not just their own
|
||||
2. **Date Restriction**: Added date validation - reports older than the day before current date cannot be duplicated
|
||||
3. **User Assignment**: Duplicated reports are always assigned to the current user (regardless of original owner)
|
||||
|
||||
### ✅ **Date Validation Logic**
|
||||
```typescript
|
||||
// Client-side validation
|
||||
const reportDate = new Date(report.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0); // Set to start of day
|
||||
|
||||
if (reportDate < dayBeforeToday) {
|
||||
return false; // Cannot duplicate
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ **Server-side Validation**
|
||||
```typescript
|
||||
// Server-side validation in duplicate action
|
||||
const reportDate = new Date(originalReport.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0);
|
||||
|
||||
if (reportDate < dayBeforeToday) {
|
||||
return json({ errors: { form: "Cannot duplicate reports older than yesterday" } }, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ **UI Improvements**
|
||||
|
||||
#### **Desktop Table View**
|
||||
- **Active Button**: Green "Duplicate" button for eligible reports
|
||||
- **Disabled State**: Gray "Duplicate" text with tooltip for old reports
|
||||
- **Tooltip**: "Cannot duplicate reports older than yesterday"
|
||||
|
||||
#### **Mobile Card View**
|
||||
- **Active Button**: Full-width green button for eligible reports
|
||||
- **Disabled State**: Gray disabled button with "(Too Old)" indicator
|
||||
- **Clear Messaging**: Shows why duplication is not available
|
||||
|
||||
### ✅ **Business Rules**
|
||||
|
||||
#### **Who Can Duplicate**
|
||||
- ✅ **Level 1 Users**: Can duplicate any report (within date range)
|
||||
- ✅ **Level 2+ Users**: Can duplicate any report (within date range)
|
||||
- ✅ **Cross-User**: Users can duplicate reports created by other users
|
||||
|
||||
#### **When Can Duplicate**
|
||||
- ✅ **Today's Reports**: Can be duplicated
|
||||
- ✅ **Yesterday's Reports**: Can be duplicated
|
||||
- ❌ **Older Reports**: Cannot be duplicated (day before yesterday and older)
|
||||
|
||||
#### **What Gets Duplicated**
|
||||
- ✅ All operational data (locations, equipment, pipeline, etc.)
|
||||
- ✅ Time sheet entries
|
||||
- ✅ Notes
|
||||
- ❌ Stoppages (excluded)
|
||||
- ❌ Original employee assignment (assigned to current user)
|
||||
|
||||
### ✅ **Error Messages**
|
||||
|
||||
#### **New Error Message**
|
||||
```
|
||||
"Cannot duplicate reports older than yesterday"
|
||||
```
|
||||
|
||||
#### **Existing Error Messages**
|
||||
- "Cannot duplicate report - sheet is already complete with both day and night shifts"
|
||||
- "Cannot duplicate report - night shift already exists for this date and location"
|
||||
- "Failed to duplicate report"
|
||||
|
||||
### ✅ **Use Cases**
|
||||
|
||||
#### **Primary Use Case**
|
||||
1. Employee A creates a day shift report yesterday
|
||||
2. Employee B (different user) sees the report today
|
||||
3. Employee B clicks "Duplicate" to create a night shift
|
||||
4. System creates night shift assigned to Employee B
|
||||
5. Employee B can then customize the night shift as needed
|
||||
|
||||
#### **Date Restriction Use Case**
|
||||
1. User tries to duplicate a report from 3 days ago
|
||||
2. Duplicate button is disabled/grayed out
|
||||
3. Tooltip explains: "Cannot duplicate reports older than yesterday"
|
||||
4. Prevents duplication of stale operational data
|
||||
|
||||
### ✅ **Benefits of Changes**
|
||||
|
||||
1. **Improved Collaboration**: Any user can duplicate any recent report
|
||||
2. **Data Freshness**: Prevents duplication of outdated operational data
|
||||
3. **User Ownership**: Duplicated reports belong to the duplicating user
|
||||
4. **Clear Feedback**: Visual indicators show when duplication is not available
|
||||
5. **Operational Efficiency**: Faster completion of report sheets by any team member
|
||||
|
||||
### ✅ **Security Considerations**
|
||||
|
||||
1. **Data Access**: Users can see and duplicate any report (business requirement)
|
||||
2. **Ownership**: Duplicated reports are owned by the current user
|
||||
3. **Date Validation**: Prevents misuse of old operational data
|
||||
4. **Sheet Integrity**: Maintains proper sheet relationships and validation
|
||||
|
||||
The updated duplicate feature now provides better accessibility and collaboration while maintaining data integrity through date restrictions.
|
||||
151
DUPLICATE_REPORT_FEATURE.md
Normal file
151
DUPLICATE_REPORT_FEATURE.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Duplicate Report Feature
|
||||
|
||||
## Overview
|
||||
Added a duplicate functionality for reports/shifts that allows users to create a complementary shift (day ↔ night) based on an existing report, but only when the report sheet is not complete.
|
||||
|
||||
## Feature Details
|
||||
|
||||
### ✅ **Functionality**
|
||||
- **Duplicate Button**: Available next to "View" for each report
|
||||
- **Opposite Shift**: Automatically creates the opposite shift (day → night, night → day)
|
||||
- **Data Copying**: Copies all report data EXCEPT stoppages
|
||||
- **User Assignment**: Assigns the duplicate to the current user
|
||||
- **Sheet Management**: Automatically manages the report sheet relationships
|
||||
|
||||
### ✅ **Validation Rules**
|
||||
1. **Date Restriction**: Cannot duplicate reports older than the day before current date (for all auth levels)
|
||||
2. **Universal Access**: All users (auth level 1+) can duplicate any report
|
||||
3. **Sheet Completeness**: Cannot duplicate if the sheet already has both day and night shifts
|
||||
4. **Shift Existence**: Cannot duplicate if the opposite shift already exists for the same date/location
|
||||
5. **User Confirmation**: Requires confirmation before duplicating
|
||||
|
||||
### ✅ **What Gets Copied**
|
||||
- ✅ Area, Dredger Location, Reclamation Location
|
||||
- ✅ Dredger Line Length, Shore Connection
|
||||
- ✅ Reclamation Height (base + extra)
|
||||
- ✅ Pipeline Length (main, ext1, reserve, ext2)
|
||||
- ✅ Equipment Statistics (Dozers, Exc, Loaders, Foreman, Laborer)
|
||||
- ✅ Time Sheet entries
|
||||
- ✅ Notes
|
||||
|
||||
### ❌ **What Gets Excluded**
|
||||
- ❌ Stoppages (empty array)
|
||||
- ❌ Original shift type (automatically inverted)
|
||||
- ❌ Original employee assignment (assigned to current user)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### **Database Logic**
|
||||
```typescript
|
||||
// Check if report is too old (before day before current date)
|
||||
const reportDate = new Date(originalReport.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0);
|
||||
|
||||
if (reportDate < dayBeforeToday) {
|
||||
return error("Cannot duplicate reports older than yesterday");
|
||||
}
|
||||
|
||||
// Check if sheet is complete (has both shifts)
|
||||
const existingSheet = await prisma.sheet.findUnique({
|
||||
where: {
|
||||
areaId_dredgerLocationId_reclamationLocationId_date: {
|
||||
areaId: originalReport.areaId,
|
||||
dredgerLocationId: originalReport.dredgerLocationId,
|
||||
reclamationLocationId: originalReport.reclamationLocationId,
|
||||
date: dateString
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent duplication if sheet is complete
|
||||
if (existingSheet && existingSheet.dayShiftId && existingSheet.nightShiftId) {
|
||||
return error("Cannot duplicate - sheet is complete");
|
||||
}
|
||||
```
|
||||
|
||||
### **Shift Inversion**
|
||||
```typescript
|
||||
const newShift = originalReport.shift === 'day' ? 'night' : 'day';
|
||||
```
|
||||
|
||||
### **Data Duplication**
|
||||
```typescript
|
||||
const duplicateReport = await prisma.report.create({
|
||||
data: {
|
||||
employeeId: user.id, // Current user
|
||||
shift: newShift, // Opposite shift
|
||||
// ... all other fields copied
|
||||
stoppages: [], // Empty stoppages
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## User Interface
|
||||
|
||||
### **Desktop View**
|
||||
- **Location**: Next to "View" button in the actions column
|
||||
- **Button**: Green "Duplicate" button with hover tooltip
|
||||
- **Confirmation**: Shows which shift will be created
|
||||
|
||||
### **Mobile View**
|
||||
- **Location**: Below "View Details" button
|
||||
- **Button**: Full-width green button with descriptive text
|
||||
- **Text**: "Duplicate as Night Shift" or "Duplicate as Day Shift"
|
||||
|
||||
### **Confirmation Dialog**
|
||||
```
|
||||
"Are you sure you want to duplicate this day shift as a night shift?
|
||||
Stoppages will not be copied."
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### **Validation Errors**
|
||||
1. **Date Restriction**: "Cannot duplicate reports older than yesterday"
|
||||
2. **Sheet Complete**: "Cannot duplicate report - sheet is already complete with both day and night shifts"
|
||||
3. **Shift Exists**: "Cannot duplicate report - night shift already exists for this date and location"
|
||||
4. **General Error**: "Failed to duplicate report"
|
||||
|
||||
### **Success Message**
|
||||
```
|
||||
"Report duplicated successfully as night shift!"
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### **Primary Use Case**
|
||||
1. User creates a day shift report
|
||||
2. Realizes they need a night shift for the same location/date
|
||||
3. Clicks "Duplicate" on the day shift
|
||||
4. System creates a night shift with same operational data
|
||||
5. User can then edit the night shift to add specific stoppages and time sheet adjustments
|
||||
|
||||
### **Business Logic**
|
||||
- **Operational Continuity**: Same equipment, locations, and basic setup
|
||||
- **Shift-Specific Data**: Stoppages are shift-specific and not copied
|
||||
- **User Ownership**: Each user owns their duplicated reports
|
||||
- **Sheet Completion**: Helps complete report sheets efficiently
|
||||
|
||||
## Security & Permissions
|
||||
|
||||
### **Access Control**
|
||||
- **All Users (Level 1+)**: Can duplicate any report within the allowed date range
|
||||
- **Date Restriction**: Reports older than the day before current date cannot be duplicated
|
||||
- **Sheet Validation**: Server-side validation prevents invalid duplications
|
||||
|
||||
### **Data Integrity**
|
||||
- **Sheet Management**: Automatically updates sheet relationships
|
||||
- **Unique Constraints**: Prevents duplicate shifts for same date/location
|
||||
- **Transaction Safety**: Uses database transactions for consistency
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Efficiency**: Quickly create complementary shifts
|
||||
2. **Consistency**: Maintains operational data consistency
|
||||
3. **Flexibility**: Allows shift-specific customizations
|
||||
4. **User-Friendly**: Simple one-click duplication with clear feedback
|
||||
5. **Data Integrity**: Maintains proper sheet relationships and validation
|
||||
|
||||
The duplicate feature streamlines the process of creating complementary shifts while maintaining data integrity and proper validation rules.
|
||||
212
REPORTS_STATS_FEATURE.md
Normal file
212
REPORTS_STATS_FEATURE.md
Normal file
@ -0,0 +1,212 @@
|
||||
# Reports Statistics Feature
|
||||
|
||||
## Overview
|
||||
Added a comprehensive statistics section to the Reports Management page, providing quick insights and key metrics about report activity. This gives users an at-a-glance view of operational performance and trends.
|
||||
|
||||
## Statistics Displayed
|
||||
|
||||
### ✅ **Core Metrics**
|
||||
1. **Total Reports**: Overall count of all reports in the system
|
||||
2. **Day Shift Count**: Number of day shift reports
|
||||
3. **Night Shift Count**: Number of night shift reports
|
||||
4. **Today's Reports**: Reports created today
|
||||
5. **Yesterday's Reports**: Reports created yesterday
|
||||
6. **Average Per Day**: Average reports per day over the last 7 days
|
||||
|
||||
### ✅ **Additional Insights**
|
||||
1. **Shift Distribution**: Percentage breakdown of day vs night shifts
|
||||
2. **Today vs Yesterday**: Comparison showing increase/decrease
|
||||
3. **Weekly Trend**: Average daily report count for trend analysis
|
||||
|
||||
## User Interface
|
||||
|
||||
### ✅ **Stats Layout**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Quick Statistics │
|
||||
│ Overview of report activity │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [125] [68] [57] [8] [12] [7.3] │
|
||||
│ Total Day Night Today Yesterday Avg/Day │
|
||||
│ Reports Shifts Shifts (7d) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Shift Distribution: 54% Day, 46% Night │
|
||||
│ Today vs Yesterday: -4 Weekly Trend: 7.3 reports/day│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ✅ **Visual Design**
|
||||
- **Color-Coded Cards**: Each metric has distinct background colors
|
||||
- Gray: Total reports (neutral)
|
||||
- Yellow: Day shifts (sun theme)
|
||||
- Blue: Night shifts (moon theme)
|
||||
- Green: Today (current/active)
|
||||
- Orange: Yesterday (recent past)
|
||||
- Indigo: Average (analytical)
|
||||
|
||||
- **Responsive Grid**: Adapts from 2 columns on mobile to 6 on desktop
|
||||
- **Clear Typography**: Large numbers with descriptive labels
|
||||
- **Insights Section**: Additional context below main metrics
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### ✅ **Server-Side Calculations**
|
||||
```typescript
|
||||
// Date calculations
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
// Parallel database queries for performance
|
||||
const stats = await Promise.all([
|
||||
prisma.report.count(), // Total reports
|
||||
prisma.report.count({ where: { shift: 'day' } }), // Day shifts
|
||||
prisma.report.count({ where: { shift: 'night' } }), // Night shifts
|
||||
prisma.report.count({ // Today's reports
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: today,
|
||||
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.report.count({ // Yesterday's reports
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: yesterday,
|
||||
lt: today
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.report.count({ // Last 7 days for average
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: sevenDaysAgo
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const averagePerDay = Math.round((last7DaysCount / 7) * 10) / 10;
|
||||
```
|
||||
|
||||
### ✅ **Performance Optimization**
|
||||
- **Parallel Queries**: All statistics calculated simultaneously
|
||||
- **Efficient Counting**: Uses `count()` instead of fetching full records
|
||||
- **Date Range Optimization**: Precise date boundaries for accurate counts
|
||||
- **Single Database Round Trip**: All stats fetched together
|
||||
|
||||
## Business Value
|
||||
|
||||
### ✅ **Operational Insights**
|
||||
1. **Activity Monitoring**: Track daily report submission patterns
|
||||
2. **Shift Balance**: Monitor day vs night shift coverage
|
||||
3. **Trend Analysis**: Identify increases/decreases in activity
|
||||
4. **Performance Tracking**: Average daily output metrics
|
||||
5. **Quick Assessment**: Instant overview without detailed analysis
|
||||
|
||||
### ✅ **Management Benefits**
|
||||
1. **Dashboard View**: Key metrics at a glance
|
||||
2. **Trend Identification**: Spot patterns in reporting activity
|
||||
3. **Resource Planning**: Understand shift distribution
|
||||
4. **Performance Monitoring**: Track team productivity
|
||||
5. **Data-Driven Decisions**: Quantified operational insights
|
||||
|
||||
## Use Cases
|
||||
|
||||
### **Daily Operations**
|
||||
1. **Morning Review**: Check yesterday's vs today's activity
|
||||
2. **Shift Planning**: See current day/night distribution
|
||||
3. **Activity Monitoring**: Track if reporting is on track
|
||||
4. **Trend Spotting**: Notice unusual patterns quickly
|
||||
|
||||
### **Management Reporting**
|
||||
1. **Weekly Reviews**: Use 7-day average for trend analysis
|
||||
2. **Shift Analysis**: Evaluate day vs night productivity
|
||||
3. **Performance Metrics**: Track overall reporting activity
|
||||
4. **Resource Allocation**: Plan based on activity patterns
|
||||
|
||||
### **Operational Planning**
|
||||
1. **Capacity Planning**: Use averages for future planning
|
||||
2. **Shift Scheduling**: Balance based on historical patterns
|
||||
3. **Performance Targets**: Set goals based on current averages
|
||||
4. **Anomaly Detection**: Spot unusual activity levels
|
||||
|
||||
## Statistics Breakdown
|
||||
|
||||
### ✅ **Metric Definitions**
|
||||
- **Total Reports**: All reports ever created in the system
|
||||
- **Day/Night Shifts**: Count by shift type across all time
|
||||
- **Today**: Reports with createdDate from 00:00:00 to 23:59:59 today
|
||||
- **Yesterday**: Reports from 00:00:00 to 23:59:59 yesterday
|
||||
- **7-Day Average**: Total reports in last 7 days divided by 7, rounded to 1 decimal
|
||||
|
||||
### ✅ **Calculated Insights**
|
||||
- **Shift Distribution**: Percentage of day vs night shifts
|
||||
- **Daily Comparison**: Difference between today and yesterday
|
||||
- **Weekly Trend**: Average daily reports for trend analysis
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### ✅ **Mobile (2 columns)**
|
||||
```
|
||||
[125] [68]
|
||||
Total Day
|
||||
[57] [8]
|
||||
Night Today
|
||||
[12] [7.3]
|
||||
Yest Avg
|
||||
```
|
||||
|
||||
### ✅ **Tablet (3 columns)**
|
||||
```
|
||||
[125] [68] [57]
|
||||
Total Day Night
|
||||
[8] [12] [7.3]
|
||||
Today Yest Avg
|
||||
```
|
||||
|
||||
### ✅ **Desktop (6 columns)**
|
||||
```
|
||||
[125] [68] [57] [8] [12] [7.3]
|
||||
Total Day Night Today Yest Avg
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### **Potential Additions**
|
||||
1. **Time-Based Charts**: Visual trends over time
|
||||
2. **Area Breakdown**: Statistics by operational area
|
||||
3. **Employee Metrics**: Top contributors and activity levels
|
||||
4. **Equipment Usage**: Statistics by equipment type
|
||||
5. **Comparative Analysis**: Month-over-month comparisons
|
||||
6. **Export Functionality**: Download statistics as reports
|
||||
7. **Real-Time Updates**: Live updating statistics
|
||||
8. **Custom Date Ranges**: User-selectable time periods
|
||||
|
||||
### **Advanced Analytics**
|
||||
1. **Predictive Trends**: Forecast future activity levels
|
||||
2. **Seasonal Patterns**: Identify recurring patterns
|
||||
3. **Performance Benchmarks**: Compare against targets
|
||||
4. **Efficiency Metrics**: Reports per hour/shift analysis
|
||||
5. **Quality Indicators**: Completion rates and accuracy metrics
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### ✅ **Database Optimization**
|
||||
- Indexed `createdDate` column for fast date range queries
|
||||
- Indexed `shift` column for quick shift-based counts
|
||||
- Efficient `COUNT()` queries instead of full data retrieval
|
||||
- Parallel query execution for better performance
|
||||
|
||||
### ✅ **Caching Opportunities**
|
||||
- Statistics could be cached for frequently accessed data
|
||||
- Daily stats could be pre-calculated and stored
|
||||
- Real-time updates vs cached performance trade-offs
|
||||
|
||||
The statistics feature provides valuable operational insights and transforms the Reports page into a comprehensive dashboard for monitoring and analyzing report activity patterns."
|
||||
246
REPORT_SHEETS_FILTER_FEATURE.md
Normal file
246
REPORT_SHEETS_FILTER_FEATURE.md
Normal file
@ -0,0 +1,246 @@
|
||||
# Report Sheets Filter Feature
|
||||
|
||||
## Overview
|
||||
Added comprehensive filtering functionality to the Report Sheets page, allowing users to filter sheets by multiple criteria for better data organization and analysis. This complements the existing filters on the Reports page.
|
||||
|
||||
## Filter Options
|
||||
|
||||
### ✅ **Available Filters**
|
||||
1. **Date Range**
|
||||
- Date From: Start date for filtering
|
||||
- Date To: End date for filtering
|
||||
- Max date: Today (prevents future dates)
|
||||
|
||||
2. **Status**
|
||||
- All Status (default)
|
||||
- Pending: Sheets with only one shift
|
||||
- Completed: Sheets with both day and night shifts
|
||||
|
||||
3. **Area**
|
||||
- All Areas (default)
|
||||
- Specific operational areas
|
||||
|
||||
4. **Employee**
|
||||
- All Employees (default)
|
||||
- Specific active employees (filters sheets where employee has at least one shift)
|
||||
|
||||
5. **Dredger Location**
|
||||
- All Dredger Locations (default)
|
||||
- Specific dredger locations with class indicators
|
||||
|
||||
## User Interface
|
||||
|
||||
### ✅ **Filter Panel**
|
||||
- **Toggle Button**: \"Show Filters\" / \"Hide Filters\" with filter icon
|
||||
- **Active Filter Indicator**: Shows count of active filters
|
||||
- **Clear All Button**: Removes all active filters
|
||||
- **Responsive Grid**: Adapts to screen size (1-5 columns)
|
||||
- **Results Summary**: Shows filtered result count
|
||||
|
||||
### ✅ **Filter Layout**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Show Filters] [3 active] [Clear All Filters] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Date From | Date To | Status | Area | Employee | Dredger │
|
||||
│ [______] | [____] | [____] | [__] | [______] | [_______] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Showing 12 sheets matching your filters │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### ✅ **Server-Side Filtering**
|
||||
```typescript
|
||||
// Build dynamic where clause
|
||||
const whereClause: any = {};
|
||||
|
||||
// Date range filter
|
||||
if (dateFrom || dateTo) {
|
||||
if (dateFrom) {
|
||||
whereClause.date = { gte: dateFrom };
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.date = { ...whereClause.date, lte: dateTo };
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (status && status !== 'all') {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
// Area filter
|
||||
if (areaId && areaId !== 'all') {
|
||||
whereClause.areaId = parseInt(areaId);
|
||||
}
|
||||
|
||||
// Dredger location filter
|
||||
if (dredgerLocationId && dredgerLocationId !== 'all') {
|
||||
whereClause.dredgerLocationId = parseInt(dredgerLocationId);
|
||||
}
|
||||
|
||||
// Employee filter (applied post-query due to complex relationship)
|
||||
if (employeeId && employeeId !== 'all') {
|
||||
sheets = sheets.filter((sheet: any) =>
|
||||
(sheet.dayShift && sheet.dayShift.employeeId === parseInt(employeeId)) ||
|
||||
(sheet.nightShift && sheet.nightShift.employeeId === parseInt(employeeId))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ **Client-Side State Management**
|
||||
```typescript
|
||||
// Filter state management
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Filter change handler
|
||||
const handleFilterChange = (filterName: string, value: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (value === '' || value === 'all') {
|
||||
newSearchParams.delete(filterName);
|
||||
} else {
|
||||
newSearchParams.set(filterName, value);
|
||||
}
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
```
|
||||
|
||||
### ✅ **URL Parameter Mapping**
|
||||
- `dateFrom` - Start date (YYYY-MM-DD)
|
||||
- `dateTo` - End date (YYYY-MM-DD)
|
||||
- `status` - Sheet status (pending/completed)
|
||||
- `areaId` - Area ID (integer)
|
||||
- `employeeId` - Employee ID (integer)
|
||||
- `dredgerLocationId` - Dredger location ID (integer)
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ **Smart Filtering**
|
||||
1. **URL Persistence**: Filters persist in URL for bookmarking/sharing
|
||||
2. **Real-time Updates**: Filters apply immediately on change
|
||||
3. **Server-side Processing**: Efficient database queries
|
||||
4. **Active Filter Tracking**: Visual indicators for applied filters
|
||||
5. **Clear All**: One-click filter reset
|
||||
|
||||
### ✅ **Responsive Design**
|
||||
- **Mobile**: Single column filter layout
|
||||
- **Tablet**: 2-3 column layout
|
||||
- **Desktop**: Up to 5 column layout
|
||||
- **Collapsible**: Filters can be hidden to save space
|
||||
|
||||
### ✅ **User Experience**
|
||||
- **Progressive Disclosure**: Filters hidden by default
|
||||
- **Visual Feedback**: Active filter count and clear indicators
|
||||
- **Results Summary**: Shows filtered result count
|
||||
- **Intuitive Controls**: Standard form inputs with clear labels
|
||||
|
||||
## Filter Logic
|
||||
|
||||
### ✅ **Date Filtering**
|
||||
- Filters by sheet date (not creation date)
|
||||
- Supports date ranges with from/to dates
|
||||
- Prevents future date selection
|
||||
|
||||
### ✅ **Status Filtering**
|
||||
- **Pending**: Sheets with only day OR night shift
|
||||
- **Completed**: Sheets with both day AND night shifts
|
||||
- Based on sheet status field
|
||||
|
||||
### ✅ **Employee Filtering**
|
||||
- Shows sheets where the selected employee has at least one shift
|
||||
- Checks both day and night shift assignments
|
||||
- Applied after database query due to complex relationship
|
||||
|
||||
### ✅ **Location Filtering**
|
||||
- Filters by area and dredger location
|
||||
- Uses direct foreign key relationships
|
||||
- Efficient database-level filtering
|
||||
|
||||
## Use Cases
|
||||
|
||||
### **Common Filtering Scenarios**
|
||||
|
||||
1. **Date Range Analysis**
|
||||
- Filter sheets from last week
|
||||
- View sheets for specific date range
|
||||
- Analyze monthly performance
|
||||
|
||||
2. **Status-Based Views**
|
||||
- Show only pending sheets (incomplete)
|
||||
- View completed sheets only
|
||||
- Track completion rates
|
||||
|
||||
3. **Location-Based Filtering**
|
||||
- View sheets for specific areas
|
||||
- Analyze dredger location performance
|
||||
- Location-specific reporting
|
||||
|
||||
4. **Employee Performance**
|
||||
- View sheets by specific employee
|
||||
- Track individual contributions
|
||||
- Employee-specific analysis
|
||||
|
||||
5. **Combined Filtering**
|
||||
- Pending sheets from Area A last week
|
||||
- Specific employee's completed sheets
|
||||
- Location + date + status combinations
|
||||
|
||||
## Benefits
|
||||
|
||||
### ✅ **Operational Benefits**
|
||||
1. **Faster Data Access**: Quick filtering vs manual searching
|
||||
2. **Better Analysis**: Focus on specific data subsets
|
||||
3. **Improved Productivity**: Less time finding relevant sheets
|
||||
4. **Enhanced Reporting**: Filtered data for management reports
|
||||
|
||||
### ✅ **Technical Benefits**
|
||||
1. **Performance**: Server-side filtering reduces data transfer
|
||||
2. **Scalability**: Efficient database queries with proper indexing
|
||||
3. **Maintainability**: Clean, modular filter implementation
|
||||
4. **Extensibility**: Easy to add new filter criteria
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### ✅ **Database Optimization**
|
||||
- Indexed columns for common filters (date, areaId, dredgerLocationId)
|
||||
- Efficient WHERE clause construction
|
||||
- Proper date range handling
|
||||
- Complex employee filtering handled efficiently
|
||||
|
||||
### ✅ **UI Performance**
|
||||
- Efficient re-renders with proper state management
|
||||
- Minimal DOM updates
|
||||
- Fast filter application
|
||||
|
||||
## Integration with Existing Features
|
||||
|
||||
### ✅ **Consistent with Reports Page**
|
||||
- Similar filter UI and behavior
|
||||
- Consistent URL parameter patterns
|
||||
- Same responsive design principles
|
||||
- Matching user experience
|
||||
|
||||
### ✅ **Permission Handling**
|
||||
- Respects user auth levels
|
||||
- Level 1 users see filtered results based on their sheets
|
||||
- Admin users see all filtered results
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### **Potential Additions**
|
||||
1. **Saved Filters**: Allow users to save common filter combinations
|
||||
2. **Advanced Filters**: More complex filtering options
|
||||
3. **Export Filtered Data**: Export only filtered results
|
||||
4. **Filter Presets**: Quick access to common filter combinations
|
||||
5. **Search**: Text search within sheet data
|
||||
|
||||
### **Advanced Features**
|
||||
1. **Date Shortcuts**: \"Last 7 days\", \"This month\", etc.
|
||||
2. **Multi-Select**: Select multiple areas or employees
|
||||
3. **Custom Date Ranges**: More flexible date selection
|
||||
4. **Filter History**: Remember recent filter combinations
|
||||
|
||||
The filter feature significantly improves the usability of the Report Sheets page, making it easier for users to find and analyze specific sheet data based on multiple criteria."
|
||||
244
STOPPAGES_ANALYSIS_FEATURE.md
Normal file
244
STOPPAGES_ANALYSIS_FEATURE.md
Normal file
@ -0,0 +1,244 @@
|
||||
# Stoppages Analysis Feature
|
||||
|
||||
## Overview
|
||||
Created a comprehensive stoppages analysis page that aggregates and displays all operational stoppages from report sheets. This provides valuable insights into operational efficiency, downtime patterns, and areas for improvement.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ **Comprehensive Data Analysis**
|
||||
1. **All Stoppages**: Shows stoppages from both day and night shifts across all report sheets
|
||||
2. **Detailed Information**: Displays time, reason, responsible party, and notes for each stoppage
|
||||
3. **Summary Statistics**: Provides overall metrics and averages
|
||||
4. **Filtering**: Multiple filter options for focused analysis
|
||||
5. **Time Calculations**: Automatic time aggregation and formatting
|
||||
|
||||
### ✅ **Summary Statistics Dashboard**
|
||||
- **Total Stoppages**: Count of all stoppage incidents
|
||||
- **Total Time**: Sum of all stoppage durations
|
||||
- **Sheets with Stoppages**: Number of sheets that have recorded stoppages
|
||||
- **Average Stoppages per Sheet**: Mean number of stoppages per sheet
|
||||
- **Average Time per Sheet**: Mean stoppage duration per sheet
|
||||
|
||||
### ✅ **Filter Options**
|
||||
1. **Date Range**: From/To date filtering
|
||||
2. **Area**: Filter by operational area
|
||||
3. **Employee**: Filter by specific employee
|
||||
4. **Dredger Location**: Filter by dredger location
|
||||
5. **Real-time Filtering**: Immediate results update
|
||||
|
||||
## User Interface
|
||||
|
||||
### ✅ **Statistics Dashboard**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stoppages Summary - Overall statistics for selected period │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [45] [12:30] [15] [3.0] [00:50] │
|
||||
│ Total Total Sheets Avg/Sheet Avg Time │
|
||||
│ Stoppages Time w/Stops /Sheet │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ✅ **Detailed Stoppages Table**
|
||||
- **Date & Shift**: When the stoppages occurred
|
||||
- **Location**: Area, dredger, and reclamation locations
|
||||
- **Employee**: Who reported the stoppages
|
||||
- **Stoppages**: Detailed breakdown of each stoppage
|
||||
- **Total Time**: Aggregated time for all stoppages in that shift
|
||||
|
||||
### ✅ **Mobile-Responsive Design**
|
||||
- **Desktop**: Full table with detailed columns
|
||||
- **Mobile**: Card-based layout with collapsible details
|
||||
- **Responsive Filters**: Adapts to screen size
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### ✅ **Data Processing**
|
||||
```typescript
|
||||
// Process stoppages from both day and night shifts
|
||||
sheets.forEach(sheet => {
|
||||
// Day shift stoppages
|
||||
if (sheet.dayShift && Array.isArray(sheet.dayShift.stoppages)) {
|
||||
const stoppages = sheet.dayShift.stoppages as StoppageEntry[];
|
||||
const totalTime = stoppages.reduce((sum, stoppage) =>
|
||||
sum + timeToMinutes(stoppage.total), 0);
|
||||
// Add to stoppagesData array
|
||||
}
|
||||
|
||||
// Night shift stoppages (similar processing)
|
||||
});
|
||||
```
|
||||
|
||||
### ✅ **Time Calculations**
|
||||
```typescript
|
||||
// Convert time string to minutes for calculations
|
||||
const timeToMinutes = (timeStr: string): number => {
|
||||
if (!timeStr || timeStr === '00:00') return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return (hours * 60) + minutes;
|
||||
};
|
||||
|
||||
// Format minutes back to HH:MM display
|
||||
const formatMinutesToTime = (minutes: number): string => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||
};
|
||||
```
|
||||
|
||||
### ✅ **Filtering Logic**
|
||||
- **Server-side**: Initial filtering by date, area, and location
|
||||
- **Post-processing**: Employee filtering after data aggregation
|
||||
- **URL Persistence**: Filters maintained in URL parameters
|
||||
- **Real-time Updates**: Immediate filter application
|
||||
|
||||
## Data Structure
|
||||
|
||||
### ✅ **Stoppage Entry**
|
||||
```typescript
|
||||
interface StoppageEntry {
|
||||
id: string;
|
||||
from: string; // Start time
|
||||
to: string; // End time
|
||||
total: string; // Duration (HH:MM)
|
||||
reason: string; // Reason for stoppage
|
||||
responsible: string; // Who/what was responsible
|
||||
note: string; // Additional notes
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ **Processed Data**
|
||||
```typescript
|
||||
interface StoppageData {
|
||||
sheetId: number;
|
||||
date: string;
|
||||
area: string;
|
||||
dredgerLocation: string;
|
||||
reclamationLocation: string;
|
||||
shift: string; // 'Day' or 'Night'
|
||||
employee: string;
|
||||
stoppages: StoppageEntry[];
|
||||
totalStoppageTime: number; // Total minutes
|
||||
}
|
||||
```
|
||||
|
||||
## Business Value
|
||||
|
||||
### ✅ **Operational Insights**
|
||||
1. **Downtime Analysis**: Identify patterns in operational stoppages
|
||||
2. **Efficiency Metrics**: Track overall operational efficiency
|
||||
3. **Problem Areas**: Identify locations or shifts with frequent stoppages
|
||||
4. **Responsibility Tracking**: See who/what causes most stoppages
|
||||
5. **Trend Analysis**: Monitor improvements over time
|
||||
|
||||
### ✅ **Management Benefits**
|
||||
1. **Performance Monitoring**: Track operational performance metrics
|
||||
2. **Resource Planning**: Identify areas needing attention or resources
|
||||
3. **Cost Analysis**: Understand the impact of downtime
|
||||
4. **Process Improvement**: Data-driven decisions for optimization
|
||||
5. **Reporting**: Comprehensive stoppage reports for stakeholders
|
||||
|
||||
## Use Cases
|
||||
|
||||
### **Operational Analysis**
|
||||
1. **Daily Review**: Check yesterday's stoppages and their causes
|
||||
2. **Weekly Trends**: Analyze patterns over the past week
|
||||
3. **Area Comparison**: Compare stoppage rates between different areas
|
||||
4. **Shift Analysis**: Compare day vs night shift performance
|
||||
5. **Employee Performance**: Track individual performance metrics
|
||||
|
||||
### **Management Reporting**
|
||||
1. **Monthly Reports**: Generate monthly stoppage summaries
|
||||
2. **Efficiency Metrics**: Calculate operational efficiency percentages
|
||||
3. **Cost Impact**: Estimate financial impact of stoppages
|
||||
4. **Improvement Tracking**: Monitor the effectiveness of improvements
|
||||
5. **Benchmarking**: Compare performance across different periods
|
||||
|
||||
### **Process Improvement**
|
||||
1. **Root Cause Analysis**: Identify most common stoppage reasons
|
||||
2. **Prevention Planning**: Plan preventive measures based on data
|
||||
3. **Resource Allocation**: Allocate resources to problem areas
|
||||
4. **Training Needs**: Identify training needs based on stoppage patterns
|
||||
5. **Equipment Maintenance**: Schedule maintenance based on stoppage data
|
||||
|
||||
## Statistics Calculations
|
||||
|
||||
### ✅ **Summary Metrics**
|
||||
- **Total Stoppages**: Sum of all individual stoppage incidents
|
||||
- **Total Time**: Sum of all stoppage durations (converted to HH:MM)
|
||||
- **Sheets with Stoppages**: Count of unique sheets that have stoppages
|
||||
- **Average Stoppages per Sheet**: Total stoppages ÷ sheets with stoppages
|
||||
- **Average Time per Sheet**: Total time ÷ sheets with stoppages
|
||||
|
||||
### ✅ **Time Aggregation**
|
||||
- Individual stoppage times are converted to minutes for calculation
|
||||
- Totals are calculated in minutes for accuracy
|
||||
- Results are converted back to HH:MM format for display
|
||||
- Proper handling of time across midnight boundaries
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### ✅ **Efficient Data Processing**
|
||||
- Single database query to fetch all relevant sheets
|
||||
- In-memory processing of JSON stoppage data
|
||||
- Efficient filtering and aggregation algorithms
|
||||
- Minimal data transfer with focused queries
|
||||
|
||||
### ✅ **Scalability**
|
||||
- Indexed date fields for fast date range queries
|
||||
- Efficient JSON processing for stoppage data
|
||||
- Pagination could be added for very large datasets
|
||||
- Caching opportunities for frequently accessed data
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### **Advanced Analytics**
|
||||
1. **Charts and Graphs**: Visual representation of stoppage trends
|
||||
2. **Comparative Analysis**: Compare periods, areas, or employees
|
||||
3. **Predictive Analytics**: Predict potential stoppage patterns
|
||||
4. **Export Functionality**: Export data to Excel or PDF
|
||||
5. **Automated Alerts**: Notify when stoppages exceed thresholds
|
||||
|
||||
### **Enhanced Filtering**
|
||||
1. **Stoppage Reason**: Filter by specific stoppage reasons
|
||||
2. **Time Range**: Filter by time of day when stoppages occurred
|
||||
3. **Duration Range**: Filter by stoppage duration (short vs long)
|
||||
4. **Responsible Party**: Filter by who was responsible
|
||||
5. **Multiple Selection**: Select multiple areas, employees, etc.
|
||||
|
||||
### **Integration Features**
|
||||
1. **Dashboard Integration**: Add stoppage widgets to main dashboard
|
||||
2. **Report Integration**: Link to related reports and sheets
|
||||
3. **Notification System**: Alert management about critical stoppages
|
||||
4. **API Endpoints**: Provide data for external systems
|
||||
5. **Mobile App**: Dedicated mobile interface for field use
|
||||
|
||||
## Counted vs Uncounted Stoppages
|
||||
|
||||
### ✅ **Classification Logic**
|
||||
- **Counted Stoppages**: Unplanned operational stoppages that impact productivity
|
||||
- **Uncounted Stoppages**: Planned operational activities that don't count as downtime
|
||||
- Entries containing "Brine" in reason or notes (planned brine operations)
|
||||
- Entries containing "Change Shift" in reason or notes (planned shift changes)
|
||||
|
||||
### ✅ **Visual Indicators**
|
||||
- **Red Background**: Counted stoppages (impact productivity)
|
||||
- **Green Background**: Uncounted stoppages (planned activities)
|
||||
- **Color-coded Badges**: "Counted" (red) vs "Uncounted" (green)
|
||||
- **Legend**: Clear explanation of the classification system
|
||||
|
||||
### ✅ **Enhanced Statistics**
|
||||
- **Total vs Counted**: Shows both total and counted stoppage counts
|
||||
- **Time Breakdown**: Separate time calculations for counted vs uncounted
|
||||
- **Percentage Analysis**: Shows what percentage of stoppages are counted
|
||||
- **Averages**: Separate averages for total and counted stoppages per sheet
|
||||
|
||||
## Menu Integration
|
||||
|
||||
### ✅ **Dashboard Navigation**
|
||||
- Added "Stoppages" menu item to the main navigation
|
||||
- Accessible to users with auth level 2+ (management and admin)
|
||||
- Clock icon for easy identification
|
||||
- Consistent with other navigation items
|
||||
|
||||
The stoppages analysis feature provides comprehensive insights into operational efficiency and helps identify areas for improvement, making it a valuable tool for operational management and continuous improvement initiatives. The counted vs uncounted classification ensures that only genuine operational stoppages are considered in productivity metrics."
|
||||
@ -242,6 +242,17 @@ function SidebarContent({
|
||||
Reports
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/stoppages"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Stoppages
|
||||
</NavItem>
|
||||
|
||||
{user.authLevel >= 2 && (
|
||||
<>
|
||||
<SectionHeader>Management</SectionHeader>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { requireAuthLevel } from "~/utils/auth.server";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import ReportSheetViewModal from "~/components/ReportSheetViewModal";
|
||||
@ -23,8 +23,46 @@ interface ReportSheet {
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireAuthLevel(request, 1);
|
||||
|
||||
// Get sheets with related data
|
||||
// Parse URL search parameters for filters
|
||||
const url = new URL(request.url);
|
||||
const dateFrom = url.searchParams.get('dateFrom');
|
||||
const dateTo = url.searchParams.get('dateTo');
|
||||
const status = url.searchParams.get('status');
|
||||
const areaId = url.searchParams.get('areaId');
|
||||
const employeeId = url.searchParams.get('employeeId');
|
||||
const dredgerLocationId = url.searchParams.get('dredgerLocationId');
|
||||
|
||||
// Build where clause based on filters
|
||||
const whereClause: any = {};
|
||||
|
||||
// Date range filter
|
||||
if (dateFrom || dateTo) {
|
||||
if (dateFrom) {
|
||||
whereClause.date = { gte: dateFrom };
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.date = { ...whereClause.date, lte: dateTo };
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (status && status !== 'all') {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
// Area filter
|
||||
if (areaId && areaId !== 'all') {
|
||||
whereClause.areaId = parseInt(areaId);
|
||||
}
|
||||
|
||||
// Dredger location filter
|
||||
if (dredgerLocationId && dredgerLocationId !== 'all') {
|
||||
whereClause.dredgerLocationId = parseInt(dredgerLocationId);
|
||||
}
|
||||
|
||||
// Get filtered sheets with related data
|
||||
let sheets = await prisma.sheet.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
area: { select: { name: true } },
|
||||
@ -49,6 +87,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Apply employee filter if specified
|
||||
if (employeeId && employeeId !== 'all') {
|
||||
sheets = sheets.filter((sheet: any) =>
|
||||
(sheet.dayShift && sheet.dayShift.employeeId === parseInt(employeeId)) ||
|
||||
(sheet.nightShift && sheet.nightShift.employeeId === parseInt(employeeId))
|
||||
);
|
||||
}
|
||||
|
||||
// Filter sheets for level 1 users (only show sheets where user has at least one shift)
|
||||
if (user.authLevel === 1) {
|
||||
sheets = sheets.filter((sheet: any) =>
|
||||
@ -57,6 +103,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get dropdown data for filters
|
||||
const [areas, dredgerLocations, employees] = await Promise.all([
|
||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.employee.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
]);
|
||||
|
||||
// Transform sheets to match the expected interface
|
||||
const transformedSheets = sheets.map((sheet: any) => ({
|
||||
id: sheet.id.toString(),
|
||||
@ -69,13 +126,29 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
nightReport: sheet.nightShift
|
||||
}));
|
||||
|
||||
return json({ user, sheets: transformedSheets });
|
||||
return json({
|
||||
user,
|
||||
sheets: transformedSheets,
|
||||
areas,
|
||||
dredgerLocations,
|
||||
employees,
|
||||
filters: {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
status,
|
||||
areaId,
|
||||
employeeId,
|
||||
dredgerLocationId
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function ReportSheet() {
|
||||
const { user, sheets } = useLoaderData<typeof loader>();
|
||||
const { user, sheets, areas, dredgerLocations, employees, filters } = useLoaderData<typeof loader>();
|
||||
const [viewingSheet, setViewingSheet] = useState<ReportSheet | null>(null);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const handleView = (sheet: ReportSheet) => {
|
||||
setViewingSheet(sheet);
|
||||
@ -87,6 +160,28 @@ export default function ReportSheet() {
|
||||
setViewingSheet(null);
|
||||
};
|
||||
|
||||
// Filter functions
|
||||
const handleFilterChange = (filterName: string, value: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (value === '' || value === 'all') {
|
||||
newSearchParams.delete(filterName);
|
||||
} else {
|
||||
newSearchParams.set(filterName, value);
|
||||
}
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchParams(new URLSearchParams());
|
||||
};
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
return filters.dateFrom || filters.dateTo || filters.status || filters.areaId || filters.employeeId || filters.dredgerLocationId;
|
||||
};
|
||||
|
||||
// Get today's date for date input max values
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const getShiftBadge = (shift: string) => {
|
||||
return shift === "day"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
@ -115,6 +210,159 @@ export default function ReportSheet() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-2 sm:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
{hasActiveFilters() && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{Object.values(filters).filter(Boolean).length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters() && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-600 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-4 sm:px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{/* Date From */}
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
value={filters.dateFrom || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateFrom', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date To */}
|
||||
<div>
|
||||
<label htmlFor="dateTo" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
value={filters.dateTo || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateTo', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
value={filters.status || 'all'}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div>
|
||||
<label htmlFor="areaId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaId"
|
||||
value={filters.areaId || 'all'}
|
||||
onChange={(e) => handleFilterChange('areaId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Employee */}
|
||||
<div>
|
||||
<label htmlFor="employeeId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Employee
|
||||
</label>
|
||||
<select
|
||||
id="employeeId"
|
||||
value={filters.employeeId || 'all'}
|
||||
onChange={(e) => handleFilterChange('employeeId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Employees</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{employee.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dredger Location */}
|
||||
<div>
|
||||
<label htmlFor="dredgerLocationId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Dredger Location
|
||||
</label>
|
||||
<select
|
||||
id="dredgerLocationId"
|
||||
value={filters.dredgerLocationId || 'all'}
|
||||
onChange={(e) => handleFilterChange('dredgerLocationId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Dredger Locations</option>
|
||||
{dredgerLocations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name} ({location.class.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing <span className="font-medium">{sheets.length}</span> sheet{sheets.length !== 1 ? 's' : ''}
|
||||
{hasActiveFilters() && ' matching your filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Sheets Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="hidden lg:block">
|
||||
|
||||
@ -15,10 +15,61 @@ export const meta: MetaFunction = () => [{ title: "Reports Management - Phosphat
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireAuthLevel(request, 1); // All employees can access reports
|
||||
|
||||
// Parse URL search parameters for filters
|
||||
const url = new URL(request.url);
|
||||
const dateFrom = url.searchParams.get('dateFrom');
|
||||
const dateTo = url.searchParams.get('dateTo');
|
||||
const shift = url.searchParams.get('shift');
|
||||
const areaId = url.searchParams.get('areaId');
|
||||
const employeeId = url.searchParams.get('employeeId');
|
||||
const dredgerLocationId = url.searchParams.get('dredgerLocationId');
|
||||
const search = url.searchParams.get('search');
|
||||
|
||||
// Get all reports with related data
|
||||
// Build where clause based on filters
|
||||
const whereClause: any = {};
|
||||
|
||||
// Date range filter
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Shift filter
|
||||
if (shift && shift !== 'all') {
|
||||
whereClause.shift = shift;
|
||||
}
|
||||
|
||||
// Area filter
|
||||
if (areaId && areaId !== 'all') {
|
||||
whereClause.areaId = parseInt(areaId);
|
||||
}
|
||||
|
||||
// Employee filter
|
||||
if (employeeId && employeeId !== 'all') {
|
||||
whereClause.employeeId = parseInt(employeeId);
|
||||
}
|
||||
|
||||
// Dredger location filter
|
||||
if (dredgerLocationId && dredgerLocationId !== 'all') {
|
||||
whereClause.dredgerLocationId = parseInt(dredgerLocationId);
|
||||
}
|
||||
|
||||
// Search filter (search within notes)
|
||||
if (search && search.trim() !== '') {
|
||||
whereClause.notes = {
|
||||
contains: search.trim(),
|
||||
mode: 'insensitive' // Case-insensitive search
|
||||
};
|
||||
}
|
||||
|
||||
// Get filtered reports with related data
|
||||
let reports = await prisma.report.findMany({
|
||||
// where: { employeeId: user.id },
|
||||
where: whereClause,
|
||||
orderBy: { createdDate: 'desc' },
|
||||
include: {
|
||||
employee: { select: { name: true } },
|
||||
@ -28,10 +79,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (user.authLevel === 1){
|
||||
// filter report by user id
|
||||
reports = reports.filter((report: any) => report.employeeId === user.id);
|
||||
}
|
||||
// if (user.authLevel === 1){
|
||||
// // filter report by user id
|
||||
// reports = reports.filter((report: any) => report.employeeId === user.id);
|
||||
// }
|
||||
|
||||
|
||||
// if (user.authLevel === 1) {
|
||||
@ -48,15 +99,68 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
// }
|
||||
|
||||
|
||||
// Get dropdown data for edit form only
|
||||
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([
|
||||
// Calculate statistics for the stats section
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
// Get dropdown data and statistics
|
||||
const [areas, dredgerLocations, reclamationLocations, foremen, equipment, employees, stats] = await Promise.all([
|
||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] })
|
||||
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] }),
|
||||
prisma.employee.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
// Calculate statistics
|
||||
Promise.all([
|
||||
// Total reports count
|
||||
prisma.report.count(),
|
||||
// Day shift count
|
||||
prisma.report.count({ where: { shift: 'day' } }),
|
||||
// Night shift count
|
||||
prisma.report.count({ where: { shift: 'night' } }),
|
||||
// Reports from today
|
||||
prisma.report.count({
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: today,
|
||||
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Reports from yesterday
|
||||
prisma.report.count({
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: yesterday,
|
||||
lt: today
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Reports from last 7 days for average calculation
|
||||
prisma.report.count({
|
||||
where: {
|
||||
createdDate: {
|
||||
gte: sevenDaysAgo
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
]);
|
||||
|
||||
const [totalReports, dayShiftCount, nightShiftCount, todayCount, yesterdayCount, last7DaysCount] = stats;
|
||||
const averagePerDay = Math.round((last7DaysCount / 7) * 10) / 10; // Round to 1 decimal place
|
||||
|
||||
return json({
|
||||
user,
|
||||
reports,
|
||||
@ -64,7 +168,25 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
dredgerLocations,
|
||||
reclamationLocations,
|
||||
foremen,
|
||||
equipment
|
||||
equipment,
|
||||
employees,
|
||||
stats: {
|
||||
totalReports,
|
||||
dayShiftCount,
|
||||
nightShiftCount,
|
||||
todayCount,
|
||||
yesterdayCount,
|
||||
averagePerDay
|
||||
},
|
||||
filters: {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
shift,
|
||||
areaId,
|
||||
employeeId,
|
||||
dredgerLocationId,
|
||||
search
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -240,6 +362,101 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "duplicate") {
|
||||
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 too old (before the day before current date)
|
||||
const reportDate = new Date(originalReport.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0); // Set to start of day
|
||||
|
||||
if (reportDate < dayBeforeToday) {
|
||||
return json({ errors: { form: "Cannot duplicate reports older than yesterday" } }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if the report is part of a complete sheet (both day and night shifts exist)
|
||||
const dateString = originalReport.createdDate.toISOString().split('T')[0];
|
||||
const existingSheet = await prisma.sheet.findUnique({
|
||||
where: {
|
||||
areaId_dredgerLocationId_reclamationLocationId_date: {
|
||||
areaId: originalReport.areaId,
|
||||
dredgerLocationId: originalReport.dredgerLocationId,
|
||||
reclamationLocationId: originalReport.reclamationLocationId,
|
||||
date: dateString
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If sheet exists and has both shifts, don't allow duplication
|
||||
if (existingSheet && existingSheet.dayShiftId && existingSheet.nightShiftId) {
|
||||
return json({ errors: { form: "Cannot duplicate report - sheet is already complete with both day and night shifts" } }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine the new shift (opposite of original)
|
||||
const newShift = originalReport.shift === 'day' ? 'night' : 'day';
|
||||
|
||||
// Check if the opposite shift already exists
|
||||
if (existingSheet) {
|
||||
if ((newShift === 'day' && existingSheet.dayShiftId) ||
|
||||
(newShift === 'night' && existingSheet.nightShiftId)) {
|
||||
return json({ errors: { form: `Cannot duplicate report - ${newShift} shift already exists for this date and location` } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the duplicate report with opposite shift and no stoppages
|
||||
const duplicateReport = await prisma.report.create({
|
||||
data: {
|
||||
employeeId: user.id, // Assign to current user
|
||||
shift: newShift,
|
||||
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
|
||||
notes: originalReport.notes
|
||||
}
|
||||
});
|
||||
|
||||
// Manage sheet for the new report
|
||||
await manageSheet(
|
||||
duplicateReport.id,
|
||||
newShift,
|
||||
originalReport.areaId,
|
||||
originalReport.dredgerLocationId,
|
||||
originalReport.reclamationLocationId,
|
||||
duplicateReport.createdDate
|
||||
);
|
||||
|
||||
return json({ success: `Report duplicated successfully as ${newShift} shift!` });
|
||||
} catch (error) {
|
||||
console.error('Duplicate error:', error);
|
||||
return json({ errors: { form: "Failed to duplicate report" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "delete") {
|
||||
if (typeof id !== "string") {
|
||||
return json({ errors: { form: "Invalid report ID" } }, { status: 400 });
|
||||
@ -304,14 +521,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Reports() {
|
||||
const { user, reports, areas, dredgerLocations, reclamationLocations, foremen, equipment } = useLoaderData<typeof loader>();
|
||||
const { user, reports, areas, dredgerLocations, reclamationLocations, foremen, equipment, employees, stats, filters } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [editingReport, setEditingReport] = useState<any>(null);
|
||||
const [viewingReport, setViewingReport] = useState<any>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
// Dynamic arrays state for editing only
|
||||
@ -546,6 +764,87 @@ export default function Reports() {
|
||||
return false;
|
||||
};
|
||||
|
||||
const canDuplicateReport = (report: any) => {
|
||||
// Check if report is too old (before the day before current date)
|
||||
const reportDate = new Date(report.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0); // Set to start of day
|
||||
|
||||
// If report is before the day before today, don't allow duplication
|
||||
if (reportDate < dayBeforeToday) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All users (auth level 1+) can duplicate reports, not just their own
|
||||
// The server will handle the final validation for sheet completeness
|
||||
return true;
|
||||
};
|
||||
|
||||
const isReportTooOld = (report: any) => {
|
||||
const reportDate = new Date(report.createdDate);
|
||||
const dayBeforeToday = new Date();
|
||||
dayBeforeToday.setDate(dayBeforeToday.getDate() - 1);
|
||||
dayBeforeToday.setHours(0, 0, 0, 0);
|
||||
return reportDate < dayBeforeToday;
|
||||
};
|
||||
|
||||
// Filter functions
|
||||
const handleFilterChange = (filterName: string, value: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (value === '' || value === 'all') {
|
||||
newSearchParams.delete(filterName);
|
||||
} else {
|
||||
newSearchParams.set(filterName, value);
|
||||
}
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchParams(new URLSearchParams());
|
||||
};
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
return filters.dateFrom || filters.dateTo || filters.shift || filters.areaId || filters.employeeId || filters.dredgerLocationId || filters.search;
|
||||
};
|
||||
|
||||
// Debounced search handler for better performance
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || '');
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
|
||||
// Clear existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for debounced search
|
||||
const newTimeout = setTimeout(() => {
|
||||
handleFilterChange('search', value);
|
||||
}, 500); // 500ms delay
|
||||
|
||||
setSearchTimeout(newTimeout);
|
||||
};
|
||||
|
||||
// Update search term when filters change (e.g., from URL or clear all)
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || '');
|
||||
}, [filters.search]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
};
|
||||
}, [searchTimeout]);
|
||||
|
||||
// Get today's date for date input max values
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
@ -565,6 +864,293 @@ export default function Reports() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Section */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Quick Statistics</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">Overview of report activity</p>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* Total Reports */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalReports}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Total Reports</div>
|
||||
</div>
|
||||
|
||||
{/* Day Shift Count */}
|
||||
<div className="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-800">{stats.dayShiftCount}</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">Day Shifts</div>
|
||||
</div>
|
||||
|
||||
{/* Night Shift Count */}
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-800">{stats.nightShiftCount}</div>
|
||||
<div className="text-xs text-blue-700 mt-1">Night Shifts</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Reports */}
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-800">{stats.todayCount}</div>
|
||||
<div className="text-xs text-green-700 mt-1">Today</div>
|
||||
</div>
|
||||
|
||||
{/* Yesterday's Reports */}
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-800">{stats.yesterdayCount}</div>
|
||||
<div className="text-xs text-orange-700 mt-1">Yesterday</div>
|
||||
</div>
|
||||
|
||||
{/* Average Per Day */}
|
||||
<div className="bg-indigo-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-indigo-800">{stats.averagePerDay}</div>
|
||||
<div className="text-xs text-indigo-700 mt-1">Avg/Day (7d)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Insights */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Shift Distribution:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{stats.totalReports > 0 ? Math.round((stats.dayShiftCount / stats.totalReports) * 100) : 0}% Day, {stats.totalReports > 0 ? Math.round((stats.nightShiftCount / stats.totalReports) * 100) : 0}% Night
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Today vs Yesterday:</span>
|
||||
<span className={`font-medium ${stats.todayCount >= stats.yesterdayCount ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{stats.todayCount >= stats.yesterdayCount ? '+' : ''}{stats.todayCount - stats.yesterdayCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Weekly Trend:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{stats.averagePerDay} reports/day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-2 sm:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
{hasActiveFilters() && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{Object.values(filters).filter(Boolean).length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters() && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-600 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-4 sm:px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
{/* Search Input - Full Width */}
|
||||
{/* <div className="mb-4">
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search in Report Notes
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
placeholder="Search for keywords in report notes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
{filters.search && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
handleFilterChange('search', '');
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="Clear search"
|
||||
>
|
||||
<svg className="h-4 w-4" 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>
|
||||
{filters.search && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">
|
||||
Searching for: <span className="font-medium text-gray-700">"{filters.search}"</span>
|
||||
</p>
|
||||
{searchTerm !== filters.search && (
|
||||
<span className="text-xs text-indigo-600 flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-1 h-3 w-3 text-indigo-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
{/* Date From */}
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
value={filters.dateFrom || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateFrom', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date To */}
|
||||
<div>
|
||||
<label htmlFor="dateTo" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
value={filters.dateTo || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateTo', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shift Type */}
|
||||
<div>
|
||||
<label htmlFor="shift" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Shift Type
|
||||
</label>
|
||||
<select
|
||||
id="shift"
|
||||
value={filters.shift || 'all'}
|
||||
onChange={(e) => handleFilterChange('shift', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Shifts</option>
|
||||
<option value="day">Day Shift</option>
|
||||
<option value="night">Night Shift</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div>
|
||||
<label htmlFor="areaId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaId"
|
||||
value={filters.areaId || 'all'}
|
||||
onChange={(e) => handleFilterChange('areaId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Employee */}
|
||||
<div>
|
||||
<label htmlFor="employeeId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Employee
|
||||
</label>
|
||||
<select
|
||||
id="employeeId"
|
||||
value={filters.employeeId || 'all'}
|
||||
onChange={(e) => handleFilterChange('employeeId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Employees</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{employee.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dredger Location */}
|
||||
<div>
|
||||
<label htmlFor="dredgerLocationId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Dredger Location
|
||||
</label>
|
||||
<select
|
||||
id="dredgerLocationId"
|
||||
value={filters.dredgerLocationId || 'all'}
|
||||
onChange={(e) => handleFilterChange('dredgerLocationId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Dredger Locations</option>
|
||||
{dredgerLocations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name} ({location.class.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing <span className="font-medium">{reports.length}</span> report{reports.length !== 1 ? 's' : ''}
|
||||
{hasActiveFilters() && (
|
||||
<span>
|
||||
{filters.search ? ' matching your search and filters' : ' matching your filters'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{filters.search && reports.length === 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
No reports found containing "{filters.search}" in their notes. Try a different search term.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
@ -632,6 +1218,32 @@ export default function Reports() {
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{canDuplicateReport(report) ? (
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="duplicate" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
const oppositeShift = report.shift === 'day' ? 'night' : 'day';
|
||||
if (!confirm(`Are you sure you want to duplicate this ${report.shift} shift as a ${oppositeShift} shift? Stoppages will not be copied.`)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="text-green-600 hover:text-green-900 transition-colors duration-150"
|
||||
title={`Duplicate as ${report.shift === 'day' ? 'night' : 'day'} shift`}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
</Form>
|
||||
) : isReportTooOld(report) ? (
|
||||
<span
|
||||
className="text-gray-400 cursor-not-allowed"
|
||||
title="Cannot duplicate reports older than yesterday"
|
||||
>
|
||||
Duplicate
|
||||
</span>
|
||||
) : null}
|
||||
{canEditReport(report) && (
|
||||
<>
|
||||
<button
|
||||
@ -716,6 +1328,32 @@ export default function Reports() {
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{canDuplicateReport(report) ? (
|
||||
<Form method="post" className="w-full">
|
||||
<input type="hidden" name="intent" value="duplicate" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
const oppositeShift = report.shift === 'day' ? 'night' : 'day';
|
||||
if (!confirm(`Are you sure you want to duplicate this ${report.shift} shift as a ${oppositeShift} shift? Stoppages will not be copied.`)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full text-center px-3 py-2 text-sm text-green-600 bg-green-50 rounded-md hover:bg-green-100 transition-colors duration-150"
|
||||
>
|
||||
Duplicate as {report.shift === 'day' ? 'Night' : 'Day'} Shift
|
||||
</button>
|
||||
</Form>
|
||||
) : isReportTooOld(report) ? (
|
||||
<button
|
||||
disabled
|
||||
className="w-full text-center px-3 py-2 text-sm text-gray-400 bg-gray-100 rounded-md cursor-not-allowed"
|
||||
title="Cannot duplicate reports older than yesterday"
|
||||
>
|
||||
Duplicate as {report.shift === 'day' ? 'Night' : 'Day'} Shift (Too Old)
|
||||
</button>
|
||||
) : null}
|
||||
{canEditReport(report) && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
|
||||
739
app/routes/stoppages.tsx
Normal file
739
app/routes/stoppages.tsx
Normal file
@ -0,0 +1,739 @@
|
||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { requireAuthLevel } from "~/utils/auth.server";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { useState } from "react";
|
||||
import { prisma } from "~/utils/db.server";
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: "Stoppages Analysis - Phosphat Report" }];
|
||||
|
||||
interface StoppageEntry {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
total: string;
|
||||
reason: string;
|
||||
responsible: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface StoppageData {
|
||||
sheetId: number;
|
||||
date: string;
|
||||
area: string;
|
||||
dredgerLocation: string;
|
||||
reclamationLocation: string;
|
||||
shift: string;
|
||||
employee: string;
|
||||
stoppages: StoppageEntry[];
|
||||
countedStoppages: StoppageEntry[];
|
||||
uncountedStoppages: StoppageEntry[];
|
||||
totalStoppageTime: number; // in minutes (all stoppages)
|
||||
countedStoppageTime: number; // in minutes (counted only)
|
||||
uncountedStoppageTime: number; // in minutes (uncounted only)
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireAuthLevel(request, 2);
|
||||
|
||||
// Parse URL search parameters for filters
|
||||
const url = new URL(request.url);
|
||||
const dateFrom = url.searchParams.get('dateFrom');
|
||||
const dateTo = url.searchParams.get('dateTo');
|
||||
const areaId = url.searchParams.get('areaId');
|
||||
const employeeId = url.searchParams.get('employeeId');
|
||||
const dredgerLocationId = url.searchParams.get('dredgerLocationId');
|
||||
|
||||
// Build where clause for sheets
|
||||
const whereClause: any = {};
|
||||
|
||||
// Date range filter
|
||||
if (dateFrom || dateTo) {
|
||||
if (dateFrom) {
|
||||
whereClause.date = { gte: dateFrom };
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.date = { ...whereClause.date, lte: dateTo };
|
||||
}
|
||||
}
|
||||
|
||||
// Area filter
|
||||
if (areaId && areaId !== 'all') {
|
||||
whereClause.areaId = parseInt(areaId);
|
||||
}
|
||||
|
||||
// Dredger location filter
|
||||
if (dredgerLocationId && dredgerLocationId !== 'all') {
|
||||
whereClause.dredgerLocationId = parseInt(dredgerLocationId);
|
||||
}
|
||||
|
||||
// Get sheets with their reports
|
||||
let sheets = await prisma.sheet.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
area: { select: { name: true } },
|
||||
dredgerLocation: { select: { name: true, class: true } },
|
||||
reclamationLocation: { select: { name: true } },
|
||||
dayShift: {
|
||||
include: {
|
||||
employee: { select: { name: true } }
|
||||
}
|
||||
},
|
||||
nightShift: {
|
||||
include: {
|
||||
employee: { select: { name: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get dropdown data for filters
|
||||
const [areas, dredgerLocations, employees] = await Promise.all([
|
||||
prisma.area.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
|
||||
prisma.employee.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
]);
|
||||
|
||||
// Process stoppages data
|
||||
const stoppagesData: StoppageData[] = [];
|
||||
|
||||
// Helper function to convert time string to minutes
|
||||
const timeToMinutes = (timeStr: string): number => {
|
||||
if (!timeStr || timeStr === '00:00') return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return (hours * 60) + minutes;
|
||||
};
|
||||
|
||||
// Helper function to check if a stoppage should be counted
|
||||
const isStoppageCounted = (stoppage: StoppageEntry): boolean => {
|
||||
const reason = stoppage.reason?.toLowerCase() || '';
|
||||
const note = stoppage.note?.toLowerCase() || '';
|
||||
|
||||
// Exclude stoppages with "Brine" or "Change Shift" in reason or notes
|
||||
return !(
|
||||
reason.includes('brine') ||
|
||||
reason.includes('change shift') ||
|
||||
note.includes('brine') ||
|
||||
note.includes('change shift') ||
|
||||
reason.includes('shift change') ||
|
||||
note.includes('shift change')
|
||||
);
|
||||
};
|
||||
|
||||
sheets.forEach(sheet => {
|
||||
// Process day shift stoppages
|
||||
if (sheet.dayShift && Array.isArray(sheet.dayShift.stoppages)) {
|
||||
const stoppages = sheet.dayShift.stoppages as StoppageEntry[];
|
||||
if (stoppages.length > 0) {
|
||||
const countedStoppages = stoppages.filter(isStoppageCounted);
|
||||
const uncountedStoppages = stoppages.filter(s => !isStoppageCounted(s));
|
||||
|
||||
const totalTime = stoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
const countedTime = countedStoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
const uncountedTime = uncountedStoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
|
||||
stoppagesData.push({
|
||||
sheetId: sheet.id,
|
||||
date: sheet.date,
|
||||
area: sheet.area.name,
|
||||
dredgerLocation: sheet.dredgerLocation.name,
|
||||
reclamationLocation: sheet.reclamationLocation.name,
|
||||
shift: 'Day',
|
||||
employee: sheet.dayShift.employee.name,
|
||||
stoppages,
|
||||
countedStoppages,
|
||||
uncountedStoppages,
|
||||
totalStoppageTime: totalTime,
|
||||
countedStoppageTime: countedTime,
|
||||
uncountedStoppageTime: uncountedTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process night shift stoppages
|
||||
if (sheet.nightShift && Array.isArray(sheet.nightShift.stoppages)) {
|
||||
const stoppages = sheet.nightShift.stoppages as StoppageEntry[];
|
||||
if (stoppages.length > 0) {
|
||||
const countedStoppages = stoppages.filter(isStoppageCounted);
|
||||
const uncountedStoppages = stoppages.filter(s => !isStoppageCounted(s));
|
||||
|
||||
const totalTime = stoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
const countedTime = countedStoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
const uncountedTime = uncountedStoppages.reduce((sum, stoppage) => sum + timeToMinutes(stoppage.total), 0);
|
||||
|
||||
stoppagesData.push({
|
||||
sheetId: sheet.id,
|
||||
date: sheet.date,
|
||||
area: sheet.area.name,
|
||||
dredgerLocation: sheet.dredgerLocation.name,
|
||||
reclamationLocation: sheet.reclamationLocation.name,
|
||||
shift: 'Night',
|
||||
employee: sheet.nightShift.employee.name,
|
||||
stoppages,
|
||||
countedStoppages,
|
||||
uncountedStoppages,
|
||||
totalStoppageTime: totalTime,
|
||||
countedStoppageTime: countedTime,
|
||||
uncountedStoppageTime: uncountedTime
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply employee filter if specified (post-processing)
|
||||
let filteredStoppagesData = stoppagesData;
|
||||
if (employeeId && employeeId !== 'all') {
|
||||
const selectedEmployee = employees.find(emp => emp.id === parseInt(employeeId));
|
||||
if (selectedEmployee) {
|
||||
filteredStoppagesData = stoppagesData.filter(data => data.employee === selectedEmployee.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const totalStoppages = filteredStoppagesData.reduce((sum, data) => sum + data.stoppages.length, 0);
|
||||
const countedStoppages = filteredStoppagesData.reduce((sum, data) => sum + data.countedStoppages.length, 0);
|
||||
const uncountedStoppages = filteredStoppagesData.reduce((sum, data) => sum + data.uncountedStoppages.length, 0);
|
||||
|
||||
const totalStoppageTime = filteredStoppagesData.reduce((sum, data) => sum + data.totalStoppageTime, 0);
|
||||
const countedStoppageTime = filteredStoppagesData.reduce((sum, data) => sum + data.countedStoppageTime, 0);
|
||||
const uncountedStoppageTime = filteredStoppagesData.reduce((sum, data) => sum + data.uncountedStoppageTime, 0);
|
||||
|
||||
const totalSheets = filteredStoppagesData.length;
|
||||
const averageStoppagesPerSheet = totalSheets > 0 ? Math.round((totalStoppages / totalSheets) * 10) / 10 : 0;
|
||||
const averageCountedStoppagesPerSheet = totalSheets > 0 ? Math.round((countedStoppages / totalSheets) * 10) / 10 : 0;
|
||||
const averageStoppageTimePerSheet = totalSheets > 0 ? Math.round((totalStoppageTime / totalSheets) * 10) / 10 : 0;
|
||||
const averageCountedTimePerSheet = totalSheets > 0 ? Math.round((countedStoppageTime / totalSheets) * 10) / 10 : 0;
|
||||
|
||||
// Convert total time back to hours:minutes format
|
||||
const formatMinutesToTime = (minutes: number): string => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return json({
|
||||
user,
|
||||
stoppagesData: filteredStoppagesData,
|
||||
areas,
|
||||
dredgerLocations,
|
||||
employees,
|
||||
filters: {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
areaId,
|
||||
employeeId,
|
||||
dredgerLocationId
|
||||
},
|
||||
summary: {
|
||||
totalStoppages,
|
||||
countedStoppages,
|
||||
uncountedStoppages,
|
||||
totalStoppageTime: formatMinutesToTime(totalStoppageTime),
|
||||
countedStoppageTime: formatMinutesToTime(countedStoppageTime),
|
||||
uncountedStoppageTime: formatMinutesToTime(uncountedStoppageTime),
|
||||
totalStoppageTimeMinutes: totalStoppageTime,
|
||||
countedStoppageTimeMinutes: countedStoppageTime,
|
||||
uncountedStoppageTimeMinutes: uncountedStoppageTime,
|
||||
totalSheets,
|
||||
averageStoppagesPerSheet,
|
||||
averageCountedStoppagesPerSheet,
|
||||
averageStoppageTimePerSheet: formatMinutesToTime(Math.round(averageStoppageTimePerSheet)),
|
||||
averageCountedTimePerSheet: formatMinutesToTime(Math.round(averageCountedTimePerSheet))
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function Stoppages() {
|
||||
const { user, stoppagesData, areas, dredgerLocations, employees, filters, summary } = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Filter functions
|
||||
const handleFilterChange = (filterName: string, value: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (value === '' || value === 'all') {
|
||||
newSearchParams.delete(filterName);
|
||||
} else {
|
||||
newSearchParams.set(filterName, value);
|
||||
}
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchParams(new URLSearchParams());
|
||||
};
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
return filters.dateFrom || filters.dateTo || filters.areaId || filters.employeeId || filters.dredgerLocationId;
|
||||
};
|
||||
|
||||
// Get today's date for date input max values
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const getShiftBadge = (shift: string) => {
|
||||
return shift === "Day"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-blue-100 text-blue-800";
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Stoppages Analysis</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Analyze operational stoppages across all report sheets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-2 sm:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
{hasActiveFilters() && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{Object.values(filters).filter(Boolean).length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters() && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-600 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-4 sm:px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{/* Date From */}
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
value={filters.dateFrom || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateFrom', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date To */}
|
||||
<div>
|
||||
<label htmlFor="dateTo" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
value={filters.dateTo || ''}
|
||||
max={today}
|
||||
onChange={(e) => handleFilterChange('dateTo', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div>
|
||||
<label htmlFor="areaId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaId"
|
||||
value={filters.areaId || 'all'}
|
||||
onChange={(e) => handleFilterChange('areaId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Employee */}
|
||||
<div>
|
||||
<label htmlFor="employeeId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Employee
|
||||
</label>
|
||||
<select
|
||||
id="employeeId"
|
||||
value={filters.employeeId || 'all'}
|
||||
onChange={(e) => handleFilterChange('employeeId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Employees</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{employee.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dredger Location */}
|
||||
<div>
|
||||
<label htmlFor="dredgerLocationId" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Dredger Location
|
||||
</label>
|
||||
<select
|
||||
id="dredgerLocationId"
|
||||
value={filters.dredgerLocationId || 'all'}
|
||||
onChange={(e) => handleFilterChange('dredgerLocationId', e.target.value)}
|
||||
className="block w-full text-sm border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="all">All Dredger Locations</option>
|
||||
{dredgerLocations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name} ({location.class.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing <span className="font-medium">{stoppagesData.length}</span> sheet{stoppagesData.length !== 1 ? 's' : ''} with stoppages
|
||||
{hasActiveFilters() && ' matching your filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-2 sm:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-red-100 border-l-2 border-red-300 rounded"></div>
|
||||
<span className="text-sm text-gray-700">Counted Stoppages</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-green-100 border-l-2 border-green-300 rounded"></div>
|
||||
<span className="text-sm text-gray-700">Uncounted Stoppages</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Uncounted: Brine operations & shift changes (planned activities)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Stoppages Summary</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">Overall statistics for the selected period</p>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-4">
|
||||
{/* Total Stoppages */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">{summary.totalStoppages}</div>
|
||||
<div className="text-xs text-gray-700 mt-1">Total Stoppages</div>
|
||||
</div>
|
||||
|
||||
{/* Counted Stoppages */}
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-800">{summary.countedStoppages}</div>
|
||||
<div className="text-xs text-red-700 mt-1">Counted</div>
|
||||
</div>
|
||||
|
||||
{/* Uncounted Stoppages */}
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-800">{summary.uncountedStoppages}</div>
|
||||
<div className="text-xs text-green-700 mt-1">Uncounted</div>
|
||||
</div>
|
||||
|
||||
{/* Total Time */}
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-800">{summary.totalStoppageTime}</div>
|
||||
<div className="text-xs text-orange-700 mt-1">Total Time</div>
|
||||
</div>
|
||||
|
||||
{/* Counted Time */}
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-800">{summary.countedStoppageTime}</div>
|
||||
<div className="text-xs text-red-700 mt-1">Counted Time</div>
|
||||
</div>
|
||||
|
||||
{/* Uncounted Time */}
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-800">{summary.uncountedStoppageTime}</div>
|
||||
<div className="text-xs text-green-700 mt-1">Uncounted Time</div>
|
||||
</div>
|
||||
|
||||
{/* Sheets with Stoppages */}
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-800">{summary.totalSheets}</div>
|
||||
<div className="text-xs text-blue-700 mt-1">Sheets w/ Stops</div>
|
||||
</div>
|
||||
|
||||
{/* Average Counted per Sheet */}
|
||||
<div className="bg-indigo-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-indigo-800">{summary.averageCountedStoppagesPerSheet}</div>
|
||||
<div className="text-xs text-indigo-700 mt-1">Avg Counted/Sheet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Insights */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Counted vs Total:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{summary.totalStoppages > 0 ? Math.round((summary.countedStoppages / summary.totalStoppages) * 100) : 0}% counted
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Avg Total/Sheet:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{summary.averageStoppagesPerSheet}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Avg Total Time/Sheet:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{summary.averageStoppageTimePerSheet}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Avg Counted Time/Sheet:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{summary.averageCountedTimePerSheet}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stoppages Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="hidden lg:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date & Shift
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Employee
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stoppages
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Total Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stoppagesData.map((data, index) => (
|
||||
<tr key={`${data.sheetId}-${data.shift}`} className="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{new Date(data.date).toLocaleDateString('en-GB')}
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(data.shift)}`}>
|
||||
{data.shift} Shift
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
<div className="font-medium">{data.area}</div>
|
||||
<div className="text-gray-500">{data.dredgerLocation} - {data.reclamationLocation}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{data.employee}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-2">
|
||||
{data.stoppages.map((stoppage, idx) => {
|
||||
const isCounted = data.countedStoppages.some(cs => cs.id === stoppage.id);
|
||||
return (
|
||||
<div key={stoppage.id} className={`text-xs rounded p-2 ${isCounted ? 'bg-red-50 border-l-2 border-red-300' : 'bg-green-50 border-l-2 border-green-300'}`}>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-gray-900">{stoppage.reason}</span>
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${isCounted ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}`}>
|
||||
{isCounted ? 'Counted' : 'Uncounted'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-600">{stoppage.total}</span>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
{stoppage.from} - {stoppage.to}
|
||||
</div>
|
||||
{stoppage.responsible && (
|
||||
<div className="text-gray-500 mt-1">
|
||||
Responsible: {stoppage.responsible}
|
||||
</div>
|
||||
)}
|
||||
{stoppage.note && (
|
||||
<div className="text-gray-500 mt-1 italic">
|
||||
{stoppage.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Total: {Math.floor(data.totalStoppageTime / 60).toString().padStart(2, '0')}:
|
||||
{(data.totalStoppageTime % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-red-800">
|
||||
Counted: {Math.floor(data.countedStoppageTime / 60).toString().padStart(2, '0')}:
|
||||
{(data.countedStoppageTime % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.countedStoppages.length} counted, {data.uncountedStoppages.length} uncounted
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stoppages Cards - Mobile */}
|
||||
<div className="lg:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{stoppagesData.map((data, index) => (
|
||||
<div key={`${data.sheetId}-${data.shift}`} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{new Date(data.date).toLocaleDateString('en-GB')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{data.area}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(data.shift)}`}>
|
||||
{data.shift}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Employee:</span>
|
||||
<span className="text-xs text-gray-900">{data.employee}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Location:</span>
|
||||
<span className="text-xs text-gray-900">{data.dredgerLocation} - {data.reclamationLocation}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Total Time:</span>
|
||||
<span className="text-xs font-medium text-gray-900">
|
||||
{Math.floor(data.totalStoppageTime / 60).toString().padStart(2, '0')}:
|
||||
{(data.totalStoppageTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Counted Time:</span>
|
||||
<span className="text-xs font-medium text-red-800">
|
||||
{Math.floor(data.countedStoppageTime / 60).toString().padStart(2, '0')}:
|
||||
{(data.countedStoppageTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">
|
||||
Stoppages ({data.countedStoppages.length} counted, {data.uncountedStoppages.length} uncounted):
|
||||
</div>
|
||||
{data.stoppages.map((stoppage) => {
|
||||
const isCounted = data.countedStoppages.some(cs => cs.id === stoppage.id);
|
||||
return (
|
||||
<div key={stoppage.id} className={`text-xs rounded p-2 ${isCounted ? 'bg-red-50 border-l-2 border-red-300' : 'bg-green-50 border-l-2 border-green-300'}`}>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-gray-900">{stoppage.reason}</span>
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${isCounted ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}`}>
|
||||
{isCounted ? 'C' : 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-600">{stoppage.total}</span>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
{stoppage.from} - {stoppage.to}
|
||||
</div>
|
||||
{stoppage.responsible && (
|
||||
<div className="text-gray-500 mt-1">
|
||||
Responsible: {stoppage.responsible}
|
||||
</div>
|
||||
)}
|
||||
{stoppage.note && (
|
||||
<div className="text-gray-500 mt-1 italic">
|
||||
{stoppage.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stoppagesData.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No stoppages found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{hasActiveFilters()
|
||||
? "No stoppages match your current filters. Try adjusting the filters."
|
||||
: "No stoppages have been recorded yet."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user