wwvvv
This commit is contained in:
parent
b43819aa7b
commit
17f1acfbb0
@ -12,6 +12,7 @@ DATABASE_URL="file:/app/data/production.db"
|
||||
|
||||
# Security
|
||||
SESSION_SECRET="your-super-secure-session-secret-change-this-in-production-min-32-chars"
|
||||
ENCRYPTION_KEY="production-secure-encryption-key!"
|
||||
|
||||
# Super Admin Account (created on first run)
|
||||
SUPER_ADMIN="superadmin"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ node_modules
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
.env.*
|
||||
|
||||
/generated/prisma
|
||||
|
||||
|
||||
135
MOBILE_RESPONSIVENESS_SUMMARY.md
Normal file
135
MOBILE_RESPONSIVENESS_SUMMARY.md
Normal file
@ -0,0 +1,135 @@
|
||||
# Mobile Responsiveness Update Summary
|
||||
|
||||
## Overview
|
||||
All page routes in the Phosphat Report application have been updated to be fully responsive and mobile-friendly. The updates follow a mobile-first approach with consistent design patterns across all pages.
|
||||
|
||||
## Key Improvements Made
|
||||
|
||||
### 1. **Dashboard (dashboard.tsx)**
|
||||
- ✅ Responsive stats grid: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
|
||||
- ✅ Responsive typography: `text-xl sm:text-2xl`
|
||||
- ✅ Mobile-friendly recent reports layout with stacked content on small screens
|
||||
- ✅ Improved spacing and padding for mobile devices
|
||||
|
||||
### 2. **Reports Management (reports.tsx)**
|
||||
- ✅ **Dual Layout System**: Desktop table + Mobile cards
|
||||
- ✅ Desktop: Traditional table layout (hidden on mobile with `hidden md:block`)
|
||||
- ✅ Mobile: Card-based layout (visible on mobile with `md:hidden`)
|
||||
- ✅ Mobile cards include all essential information in a compact format
|
||||
- ✅ Responsive header with stacked layout on mobile
|
||||
- ✅ Mobile-friendly action buttons (full-width on mobile)
|
||||
|
||||
### 3. **Employee Management (employees.tsx)**
|
||||
- ✅ **Dual Layout System**: Desktop table + Mobile cards
|
||||
- ✅ Desktop: Full table with all columns (hidden on mobile with `hidden lg:block`)
|
||||
- ✅ Mobile: Detailed cards showing employee info, status, and actions
|
||||
- ✅ Responsive form layout in modal
|
||||
- ✅ Mobile-friendly button layouts
|
||||
|
||||
### 4. **Report Sheets (report-sheet.tsx)**
|
||||
- ✅ **Dual Layout System**: Desktop table + Mobile cards
|
||||
- ✅ Mobile cards show date, area, locations, shifts, and employees
|
||||
- ✅ Responsive shift badges and status indicators
|
||||
- ✅ Mobile-optimized action buttons
|
||||
|
||||
### 5. **New Report Form (reports_.new.tsx)**
|
||||
- ✅ Responsive multi-step progress indicator
|
||||
- ✅ Mobile-friendly step navigation with smaller indicators on mobile
|
||||
- ✅ Responsive form grids: `grid-cols-1 sm:grid-cols-2`
|
||||
- ✅ Mobile-optimized navigation buttons with stacked layout
|
||||
- ✅ Responsive spacing and padding throughout the form
|
||||
|
||||
### 6. **Areas Management (areas.tsx)**
|
||||
- ✅ **Dual Layout System**: Desktop table + Mobile cards
|
||||
- ✅ Mobile cards with area name, report count, and actions
|
||||
- ✅ Responsive header layout
|
||||
- ✅ Mobile-friendly action buttons
|
||||
|
||||
### 7. **Equipment Management (equipment.tsx)**
|
||||
- ✅ **Dual Layout System**: Desktop table + Mobile cards
|
||||
- ✅ Mobile cards showing equipment details, category badges, and actions
|
||||
- ✅ Responsive form layout in modal
|
||||
- ✅ Mobile-optimized category indicators
|
||||
|
||||
### 8. **Authentication Pages**
|
||||
- ✅ **Sign In (signin.tsx)**: Responsive logo sizing, typography, and spacing
|
||||
- ✅ **Sign Up (signup.tsx)**: Mobile-friendly form layout and responsive design
|
||||
|
||||
### 9. **Settings Pages**
|
||||
- ✅ **Mail Settings (mail-settings.tsx)**: Responsive form layout, stacked buttons on mobile
|
||||
- ✅ **Test Email (test-email.tsx)**: Mobile-friendly form and responsive typography
|
||||
|
||||
## Design Patterns Used
|
||||
|
||||
### 1. **Responsive Breakpoints**
|
||||
- `sm:` (640px+) - Small tablets and larger phones
|
||||
- `md:` (768px+) - Tablets
|
||||
- `lg:` (1024px+) - Small desktops
|
||||
- `xl:` (1280px+) - Large desktops
|
||||
|
||||
### 2. **Layout Strategies**
|
||||
- **Tables → Cards**: Desktop tables convert to mobile cards for better readability
|
||||
- **Flex Direction**: `flex-col sm:flex-row` for stacking on mobile
|
||||
- **Grid Responsiveness**: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
|
||||
- **Spacing**: Reduced padding/margins on mobile (`p-4 sm:p-6`)
|
||||
|
||||
### 3. **Typography Scaling**
|
||||
- Headers: `text-xl sm:text-2xl`
|
||||
- Body text: `text-sm sm:text-base`
|
||||
- Responsive line heights and spacing
|
||||
|
||||
### 4. **Button Layouts**
|
||||
- Mobile: Full-width buttons (`w-full`)
|
||||
- Desktop: Inline buttons with proper spacing
|
||||
- Action groups: Stacked on mobile, inline on desktop
|
||||
|
||||
### 5. **Navigation**
|
||||
- Mobile-first navigation with collapsible elements
|
||||
- Touch-friendly button sizes (minimum 44px touch targets)
|
||||
- Proper spacing for thumb navigation
|
||||
|
||||
## Mobile-Specific Features
|
||||
|
||||
### 1. **Card-Based Layouts**
|
||||
- All data tables have mobile card alternatives
|
||||
- Cards include essential information in a scannable format
|
||||
- Proper visual hierarchy with typography and spacing
|
||||
|
||||
### 2. **Touch-Friendly Interactions**
|
||||
- Larger touch targets for buttons and links
|
||||
- Proper spacing between interactive elements
|
||||
- Mobile-optimized form inputs
|
||||
|
||||
### 3. **Progressive Enhancement**
|
||||
- Desktop-first table layouts with mobile fallbacks
|
||||
- Responsive images and icons
|
||||
- Adaptive spacing and typography
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### 1. **Device Testing**
|
||||
- Test on actual mobile devices (iOS/Android)
|
||||
- Use browser dev tools for responsive testing
|
||||
- Test in both portrait and landscape orientations
|
||||
|
||||
### 2. **Breakpoint Testing**
|
||||
- Test all major breakpoints (320px, 768px, 1024px, 1280px)
|
||||
- Ensure smooth transitions between breakpoints
|
||||
- Verify no horizontal scrolling on mobile
|
||||
|
||||
### 3. **Functionality Testing**
|
||||
- All forms work properly on mobile
|
||||
- Modal dialogs are mobile-friendly
|
||||
- Navigation is accessible on touch devices
|
||||
|
||||
## Browser Support
|
||||
- Modern mobile browsers (iOS Safari, Chrome Mobile, Firefox Mobile)
|
||||
- Responsive design works across all major desktop browsers
|
||||
- Graceful degradation for older browsers
|
||||
|
||||
## Performance Considerations
|
||||
- Tailwind CSS provides optimized responsive utilities
|
||||
- No additional JavaScript required for responsive behavior
|
||||
- Minimal impact on bundle size
|
||||
|
||||
All routes now provide an excellent mobile experience while maintaining full desktop functionality.
|
||||
14
Y_Todo.md
Normal file
14
Y_Todo.md
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
|
||||
|
||||
TODO
|
||||
|
||||
- [✅] Update Sidebar menu to be collapselble
|
||||
- [✅] make the Sidebar compatible with mobile devices
|
||||
- [ ] Update all the page routes to be responsive and mobile friendly
|
||||
- [ ] Summation of stoppage hours (sum if contains) and show them under the report
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Form, Link } from "@remix-run/react";
|
||||
import { Form, Link, useLocation } from "@remix-run/react";
|
||||
import type { Employee } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -7,50 +8,87 @@ interface DashboardLayoutProps {
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children, user }: DashboardLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Initialize from localStorage if available
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('sidebar-collapsed');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
const newCollapsed = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newCollapsed);
|
||||
// Persist to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sidebar-collapsed', JSON.stringify(newCollapsed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-full mx-auto px-0 sm:px-6 lg:px-8">
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200 fixed w-full top-0 z-30">
|
||||
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 mr-3"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Desktop collapse button */}
|
||||
<button
|
||||
type="button"
|
||||
className="hidden lg:inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 mr-3"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
className="h-12 w-auto justify-self-start"
|
||||
src="/clogo-sm.png"
|
||||
className="h-14 w-auto"
|
||||
src="/logo03.png"
|
||||
alt="Phosphat Report"
|
||||
/>
|
||||
{/* <div className="ml-4">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
Phosphat Report Dashboard
|
||||
</h1>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="hidden sm:flex items-center space-x-2">
|
||||
<div className="text-sm text-gray-700">
|
||||
Welcome, <span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
{/* <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.authLevel === 1
|
||||
? 'bg-red-100 text-red-800'
|
||||
: user.authLevel === 2
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
Level {user.authLevel}
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<Form method="post" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm 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-red-500 shadow-sm transition duration-150 ease-in-out"
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm 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-red-500 shadow-sm transition duration-150 ease-in-out"
|
||||
>
|
||||
<svg className="-ml-0.5 mr-2 h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-4 w-4 sm:mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
@ -58,176 +96,253 @@ export default function DashboardLayout({ children, user }: DashboardLayoutProps
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex">
|
||||
<div className="w-64 bg-white shadow-sm min-h-screen">
|
||||
<nav className="mt-8 px-4">
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<div className="flex pt-16">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className={`hidden lg:flex lg:flex-shrink-0 transition-all duration-300 ${sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
|
||||
}`}>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col flex-grow bg-white shadow-sm border-r border-gray-200 pt-5 pb-4 overflow-y-auto">
|
||||
<SidebarContent
|
||||
user={user}
|
||||
collapsed={sidebarCollapsed}
|
||||
onItemClick={() => { }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/reports"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Shifts
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/report-sheet"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Reports
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{(user.authLevel >= 2) && (
|
||||
<>
|
||||
|
||||
{/* Management Section */}
|
||||
<li className="mt-6">
|
||||
<div className="px-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Management
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/areas"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Areas
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/dredger-locations"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
Dredger Locations
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/reclamation-locations"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Reclamation Locations
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/equipment"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Equipment
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/foreman"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Foreman
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/employees"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
Employees
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.authLevel === 3 && (
|
||||
<>
|
||||
{/* Admin Section */}
|
||||
<li className="mt-6">
|
||||
<div className="px-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Administration
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/mail-settings"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Mail Settings
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/test-email"
|
||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
Test Email
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
{/* Mobile Sidebar */}
|
||||
<div className={`lg:hidden fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div className="flex flex-col h-full pt-16">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<SidebarContent
|
||||
user={user}
|
||||
collapsed={false}
|
||||
onItemClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 p-8">
|
||||
{children}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar Content Component
|
||||
function SidebarContent({
|
||||
user,
|
||||
collapsed,
|
||||
onItemClick
|
||||
}: {
|
||||
user: Pick<Employee, "id" | "name" | "username" | "authLevel">;
|
||||
collapsed: boolean;
|
||||
onItemClick: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/dashboard';
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
const NavItem = ({
|
||||
to,
|
||||
icon,
|
||||
children,
|
||||
onClick
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const active = isActive(to);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to={to}
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
onItemClick();
|
||||
}}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md transition-colors duration-150 ${active
|
||||
? 'bg-indigo-100 text-indigo-900 border-r-2 border-indigo-500'
|
||||
: 'text-gray-900 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
title={collapsed ? children?.toString() : undefined}
|
||||
>
|
||||
<div className={`mr-3 flex-shrink-0 h-6 w-6 ${active ? 'text-indigo-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
}`}>
|
||||
{icon}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="truncate">{children}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
|
||||
<li className="mt-6">
|
||||
{!collapsed && (
|
||||
<div className="px-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{collapsed && <div className="border-t border-gray-200 mx-2"></div>}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
<ul className="space-y-1">
|
||||
<NavItem
|
||||
to="/dashboard"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Dashboard
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/reports"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Shifts
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/report-sheet"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Reports
|
||||
</NavItem>
|
||||
|
||||
{user.authLevel >= 2 && (
|
||||
<>
|
||||
<SectionHeader>Management</SectionHeader>
|
||||
|
||||
<NavItem
|
||||
to="/areas"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Areas
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/dredger-locations"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Dredger Locations
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/reclamation-locations"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Reclamation Locations
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/equipment"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Equipment
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/foreman"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Foreman
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/employees"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Employees
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.authLevel === 3 && (
|
||||
<>
|
||||
<SectionHeader>Administration</SectionHeader>
|
||||
|
||||
<NavItem
|
||||
to="/mail-settings"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Mail Settings
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/test-email"
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Test Email
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -535,7 +535,11 @@ function TimeSheetSection({ timeSheetEntries, equipment, addTimeSheetEntry, remo
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="">Select Equipment</option>
|
||||
{equipment.map((item: any) => (
|
||||
{equipment.filter((item: any) => {
|
||||
const machineValue = `${item.model} (${item.number})`;
|
||||
// Show if not selected by any other entry, or if it's the current entry's selection
|
||||
return !timeSheetEntries.some((e: any) => e.id !== entry.id && e.machine === machineValue);
|
||||
}).map((item: any) => (
|
||||
<option key={item.id} value={`${item.model} (${item.number})`}>
|
||||
{item.category} - {item.model} ({item.number})
|
||||
</option>
|
||||
|
||||
@ -21,14 +21,77 @@ export default function ReportSheetViewModal({ isOpen, onClose, sheet }: ReportS
|
||||
if (!isOpen || !sheet) return null;
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
if (sheet.dayReport) {
|
||||
await exportReportToExcel(sheet.dayReport);
|
||||
}
|
||||
if (sheet.nightReport) {
|
||||
await exportReportToExcel(sheet.nightReport);
|
||||
}
|
||||
// Export the entire sheet (both day and night reports combined)
|
||||
await exportReportToExcel(sheet);
|
||||
};
|
||||
|
||||
// Helper function to parse time string (HH:MM) to minutes
|
||||
const parseTimeToMinutes = (timeStr: string): number => {
|
||||
if (!timeStr || timeStr === '00:00') return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return (hours || 0) * 60 + (minutes || 0);
|
||||
};
|
||||
|
||||
// Helper function to format minutes back to HH:MM
|
||||
const formatMinutesToTime = (totalMinutes: number): string => {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Calculate stoppage statistics
|
||||
const calculateStoppageStats = () => {
|
||||
const dayStoppages = sheet.dayReport?.stoppages || [];
|
||||
const nightStoppages = sheet.nightReport?.stoppages || [];
|
||||
|
||||
// Calculate total time for day shift stoppages
|
||||
const totalDayMinutes = dayStoppages.reduce((sum, stoppage) => {
|
||||
return sum + parseTimeToMinutes(stoppage.total || '00:00');
|
||||
}, 0);
|
||||
|
||||
// Calculate total time for night shift stoppages
|
||||
const totalNightMinutes = nightStoppages.reduce((sum, stoppage) => {
|
||||
return sum + parseTimeToMinutes(stoppage.total || '00:00');
|
||||
}, 0);
|
||||
|
||||
// Calculate total combined time
|
||||
const totalMinutes = totalDayMinutes + totalNightMinutes;
|
||||
|
||||
// Calculate counted stoppages time (excluding "Brine" or "Change Shift" in notes)
|
||||
const countedDayMinutes = dayStoppages
|
||||
.filter(stoppage => {
|
||||
const note = (stoppage.note || '').toLowerCase();
|
||||
return !note.includes('brine') && !note.includes('change shift') && !note.includes('shift change');
|
||||
})
|
||||
.reduce((sum, stoppage) => {
|
||||
return sum + parseTimeToMinutes(stoppage.total || '00:00');
|
||||
}, 0);
|
||||
|
||||
const countedNightMinutes = nightStoppages
|
||||
.filter(stoppage => {
|
||||
const note = (stoppage.note || '').toLowerCase();
|
||||
return !note.includes('brine') && !note.includes('change shift') && !note.includes('shift change');
|
||||
})
|
||||
.reduce((sum, stoppage) => {
|
||||
return sum + parseTimeToMinutes(stoppage.total || '00:00');
|
||||
}, 0);
|
||||
|
||||
const totalCountedMinutes = countedDayMinutes + countedNightMinutes;
|
||||
|
||||
return {
|
||||
totalDayTime: formatMinutesToTime(totalDayMinutes),
|
||||
totalNightTime: formatMinutesToTime(totalNightMinutes),
|
||||
totalTime: formatMinutesToTime(totalMinutes),
|
||||
countedDayTime: formatMinutesToTime(countedDayMinutes),
|
||||
countedNightTime: formatMinutesToTime(countedNightMinutes),
|
||||
totalCountedTime: formatMinutesToTime(totalCountedMinutes),
|
||||
totalMinutes,
|
||||
totalCountedMinutes
|
||||
};
|
||||
};
|
||||
|
||||
const stoppageStats = calculateStoppageStats();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
@ -177,6 +240,120 @@ export default function ReportSheetViewModal({ isOpen, onClose, sheet }: ReportS
|
||||
|
||||
<ReportSheetFooter />
|
||||
</div>
|
||||
|
||||
{/* Summary Statistics Section */}
|
||||
<div className="mt-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-200 p-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Stoppage Summary Statistics
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Total Stoppage Time */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-600 mb-3 uppercase tracking-wide">Total Stoppage Time</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">Day Shift:</span>
|
||||
<span className="text-lg font-semibold text-blue-600 font-mono">{stoppageStats.totalDayTime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">Night Shift:</span>
|
||||
<span className="text-lg font-semibold text-purple-600 font-mono">{stoppageStats.totalNightTime}</span>
|
||||
</div>
|
||||
<div className="border-t pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-800">Total:</span>
|
||||
<span className="text-xl font-bold text-gray-900 font-mono">{stoppageStats.totalTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Counted Stoppage Time */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-600 mb-3 uppercase tracking-wide">Counted Stoppage Time</h5>
|
||||
<div className="text-xs text-gray-500 mb-3">
|
||||
{/* (Excluding "Brine" & "Change Shift") */}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">Day Shift:</span>
|
||||
<span className="text-lg font-semibold text-green-600 font-mono">{stoppageStats.countedDayTime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">Night Shift:</span>
|
||||
<span className="text-lg font-semibold text-orange-600 font-mono">{stoppageStats.countedNightTime}</span>
|
||||
</div>
|
||||
<div className="border-t pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-800">Total:</span>
|
||||
<span className="text-xl font-bold text-red-600 font-mono">{stoppageStats.totalCountedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Analysis */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-600 mb-3 uppercase tracking-wide">Time Analysis</h5>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-700">Counted Rate:</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{stoppageStats.totalMinutes > 0
|
||||
? `${Math.round((stoppageStats.totalCountedMinutes / stoppageStats.totalMinutes) * 100)}%`
|
||||
: '0%'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-green-400 to-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: stoppageStats.totalMinutes > 0
|
||||
? `${(stoppageStats.totalCountedMinutes / stoppageStats.totalMinutes) * 100}%`
|
||||
: '0%'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
|
||||
<div className="flex justify-between">
|
||||
<span>Excluded Time:</span>
|
||||
<span className="font-medium font-mono">
|
||||
{formatMinutesToTime(stoppageStats.totalMinutes - stoppageStats.totalCountedMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="text-xs text-gray-600 bg-blue-50 p-2 rounded">
|
||||
<div className="flex justify-between">
|
||||
<span>Avg per Day:</span>
|
||||
<span className="font-medium font-mono">
|
||||
{formatMinutesToTime(Math.round(stoppageStats.totalCountedMinutes / 2))}
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-4 p-3 bg-blue-100 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<strong>Note:</strong> Counted stoppage time excludes entries with "Brine" or "Change Shift" in the notes field,
|
||||
as these are typically planned operational activities rather than unplanned stoppages. Times are displayed in HH:MM format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -191,12 +368,22 @@ function ReportSheetHeader({ sheet }: { sheet: ReportSheet }) {
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r-2 border-black p-2 text-center font-bold text-lg" style={{ width: '70%' }}>
|
||||
<div>Reclamation Work Diary - Daily Sheet</div>
|
||||
<td className=" p-2 text-center" style={{ width: '25%' }}>
|
||||
<img
|
||||
src="/logo03.png"
|
||||
alt="Company Logo"
|
||||
className="h-16 mx-auto"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="border-r-2 border-l-2 border-black p-2 text-center font-bold text-lg" style={{ width: '50%' }}>
|
||||
<div>Reclamation Work Diary</div>
|
||||
<div className="border-t border-black mt-1 pt-1">QF-3.6.1-08</div>
|
||||
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
||||
</td>
|
||||
<td className="p-2 text-center" style={{ width: '30%' }}>
|
||||
<td className="p-2 text-center" style={{ width: '25%' }}>
|
||||
<img
|
||||
src="/logo-light.png"
|
||||
alt="Arab Potash Logo"
|
||||
|
||||
@ -154,12 +154,23 @@ function ReportHeader() {
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r-2 border-black p-2 text-center font-bold text-lg" style={{ width: '70%' }}>
|
||||
{/* border-r border-black */}
|
||||
<td className=" p-2 text-center" style={{ width: '25%' }}>
|
||||
<img
|
||||
src="/logo03.png"
|
||||
alt="Company Logo"
|
||||
className="h-16 mx-auto"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="border-r-2 border-l-2 border-black p-2 text-center font-bold text-lg" style={{ width: '50%' }}>
|
||||
<div>Reclamation Work Diary</div>
|
||||
<div className="border-t border-black mt-1 pt-1">QF-3.6.1-08</div>
|
||||
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
||||
</td>
|
||||
<td className="p-2 text-center" style={{ width: '30%' }}>
|
||||
<td className="p-2 text-center" style={{ width: '25%' }}>
|
||||
<img
|
||||
src="/logo-light.png"
|
||||
alt="Arab Potash Logo"
|
||||
|
||||
@ -125,14 +125,14 @@ export default function Areas() {
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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-2xl font-bold text-gray-900">Areas Management</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Areas Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Manage operational areas for your reports</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@ -141,10 +141,11 @@ export default function Areas() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Areas Table */}
|
||||
{/* Areas Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<div className="hidden sm: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">
|
||||
@ -211,7 +212,59 @@ export default function Areas() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Areas Cards - Mobile */}
|
||||
<div className="sm:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{areas.map((area) => (
|
||||
<div key={area.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-gray-900">{area.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{area._count.reports} reports
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={() => handleEdit(area)}
|
||||
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||
>
|
||||
Edit Area
|
||||
</button>
|
||||
<Form method="post" className="w-full">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={area.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this area?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full text-center px-3 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors duration-150"
|
||||
>
|
||||
Delete Area
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{areas.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">
|
||||
|
||||
@ -47,17 +47,17 @@ export default function Dashboard() {
|
||||
{/* Welcome Section */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">
|
||||
Welcome back, {user.name}!
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Here's what's happening with your phosphat operations today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
@ -129,10 +129,10 @@ export default function Dashboard() {
|
||||
{/* Recent Reports */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
<h3 className="text-base sm:text-lg leading-6 font-medium text-gray-900">
|
||||
Recent Reports
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<p className="mt-1 max-w-2xl text-xs sm:text-sm text-gray-500">
|
||||
Latest activity from your team
|
||||
</p>
|
||||
</div>
|
||||
@ -141,7 +141,7 @@ export default function Dashboard() {
|
||||
recentReports.map((report) => (
|
||||
<li key={report.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-indigo-500 flex items-center justify-center">
|
||||
@ -154,12 +154,12 @@ export default function Dashboard() {
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{report.employee.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-xs sm:text-sm text-gray-500">
|
||||
{report.area.name} - {report.shift} shift
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-xs sm:text-sm text-gray-500 ml-12 sm:ml-0">
|
||||
{new Date(report.createdDate).toLocaleDateString('en-GB')}
|
||||
</div>
|
||||
</div>
|
||||
@ -169,7 +169,7 @@ export default function Dashboard() {
|
||||
) : (
|
||||
<li>
|
||||
<div className="px-4 py-4 sm:px-6 text-center text-gray-500">
|
||||
No reports yet. Create your first report to get started!
|
||||
<p className="text-sm">No reports yet. Create your first report to get started!</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
@ -342,14 +342,14 @@ export default function Employees() {
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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-2xl font-bold text-gray-900">Employee Management</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Employee Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Manage system users and their access levels</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@ -358,10 +358,11 @@ export default function Employees() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Employees Table */}
|
||||
{/* Employees Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<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">
|
||||
@ -472,7 +473,91 @@ export default function Employees() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employees Cards - Mobile */}
|
||||
<div className="lg:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{employees.map((employee) => (
|
||||
<div key={employee.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getAuthLevelBadge(employee.authLevel)}`}>
|
||||
{getAuthLevelIcon(employee.authLevel)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-gray-900">{employee.name}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{employee.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadge(employee.status)}`}>
|
||||
{getStatusIcon(employee.status)}
|
||||
<span className="ml-1 capitalize">{employee.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Email:</span>
|
||||
<span className="text-xs text-gray-900 truncate ml-2">{employee.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Access Level:</span>
|
||||
<span className="text-xs text-gray-900">Level {employee.authLevel} - {getAuthLevelText(employee.authLevel)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Reports:</span>
|
||||
<span className="text-xs text-gray-900">{employee._count.reports} reports</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={() => handleEdit(employee)}
|
||||
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||
>
|
||||
Edit Employee
|
||||
</button>
|
||||
{employee.id !== user.id && (
|
||||
<div className="flex space-x-2">
|
||||
<Form method="post" className="flex-1">
|
||||
<input type="hidden" name="intent" value="toggleStatus" />
|
||||
<input type="hidden" name="id" value={employee.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full text-center px-3 py-2 text-sm rounded-md transition-colors duration-150 ${
|
||||
employee.status === 'active'
|
||||
? 'text-orange-600 bg-orange-50 hover:bg-orange-100'
|
||||
: 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
{employee.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</Form>
|
||||
<Form method="post" className="flex-1">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={employee.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this employee?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full text-center px-3 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{employees.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">
|
||||
@ -507,45 +592,45 @@ export default function Employees() {
|
||||
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
|
||||
{isEditing && <input type="hidden" name="id" value={editingEmployee?.id} />}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
defaultValue={editingEmployee?.name || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
{actionData?.errors?.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
defaultValue={editingEmployee?.name || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
{actionData?.errors?.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
required
|
||||
defaultValue={editingEmployee?.username || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
{actionData?.errors?.username && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
required
|
||||
defaultValue={editingEmployee?.username || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
{actionData?.errors?.username && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
@ -563,47 +648,47 @@ export default function Employees() {
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password {isEditing && <span className="text-gray-500">(leave blank to keep current)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required={!isEditing}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder={isEditing ? "Enter new password" : "Enter password"}
|
||||
/>
|
||||
{actionData?.errors?.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="authLevel" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Access Level
|
||||
</label>
|
||||
<select
|
||||
name="authLevel"
|
||||
id="authLevel"
|
||||
required
|
||||
defaultValue={editingEmployee?.authLevel || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select access level</option>
|
||||
<option value="1">Level 1 - User (Basic Access)</option>
|
||||
<option value="2">Level 2 - Admin (Management Access)</option>
|
||||
{user.authLevel === 3 && (
|
||||
<option value="3">Level 3 - Super Admin (Full Access)</option>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password {isEditing && <span className="text-gray-500">(leave blank to keep current)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required={!isEditing}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder={isEditing ? "Enter new password" : "Enter password"}
|
||||
/>
|
||||
{actionData?.errors?.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
|
||||
)}
|
||||
</select>
|
||||
{actionData?.errors?.authLevel && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="authLevel" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Access Level
|
||||
</label>
|
||||
<select
|
||||
name="authLevel"
|
||||
id="authLevel"
|
||||
required
|
||||
defaultValue={editingEmployee?.authLevel || ""}
|
||||
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select access level</option>
|
||||
<option value="1">Level 1 - User (Basic Access)</option>
|
||||
<option value="2">Level 2 - Admin (Management Access)</option>
|
||||
{user.authLevel === 3 && (
|
||||
<option value="3">Level 3 - Super Admin (Full Access)</option>
|
||||
)}
|
||||
</select>
|
||||
{actionData?.errors?.authLevel && (
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && editingEmployee?.id !== user.id && (
|
||||
|
||||
@ -166,14 +166,14 @@ export default function Equipment() {
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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-2xl font-bold text-gray-900">Equipment Management</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Equipment Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Manage your fleet equipment and machinery</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@ -182,10 +182,11 @@ export default function Equipment() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Equipment Table */}
|
||||
{/* Equipment Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<div className="hidden sm: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">
|
||||
@ -255,7 +256,57 @@ export default function Equipment() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment Cards - Mobile */}
|
||||
<div className="sm:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{equipment.map((item) => (
|
||||
<div key={item.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getCategoryBadge(item.category)}`}>
|
||||
{getCategoryIcon(item.category)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-gray-900">{item.model}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">#{item.number}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryBadge(item.category)}`}>
|
||||
{item.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={() => handleEdit(item)}
|
||||
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||
>
|
||||
Edit Equipment
|
||||
</button>
|
||||
<Form method="post" className="w-full">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this equipment?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full text-center px-3 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors duration-150"
|
||||
>
|
||||
Delete Equipment
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{equipment.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">
|
||||
|
||||
@ -5,12 +5,23 @@ import { testEmailConnection } from "~/utils/mail.server";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { useState } from "react";
|
||||
import { prisma } from "~/utils/db.server";
|
||||
import { encryptMailSettings, decryptMailSettings, safeEncryptPassword } from "~/utils/encryption.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Require auth level 3 to access mail settings
|
||||
const user = await requireAuthLevel(request, 3);
|
||||
|
||||
const mailSettings = await prisma.mailSettings.findFirst();
|
||||
const encryptedMailSettings = await prisma.mailSettings.findFirst();
|
||||
|
||||
// Decrypt settings for display (but mask the password)
|
||||
let mailSettings = null;
|
||||
if (encryptedMailSettings) {
|
||||
const decrypted = decryptMailSettings(encryptedMailSettings);
|
||||
mailSettings = {
|
||||
...decrypted,
|
||||
password: '••••••••' // Mask password for security
|
||||
};
|
||||
}
|
||||
|
||||
return json({ mailSettings, user });
|
||||
}
|
||||
@ -40,6 +51,9 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Encrypt the password before saving
|
||||
const encryptedPassword = safeEncryptPassword(password);
|
||||
|
||||
// Check if settings exist
|
||||
const existingSettings = await prisma.mailSettings.findFirst();
|
||||
|
||||
@ -52,7 +66,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
port,
|
||||
secure,
|
||||
username,
|
||||
password,
|
||||
password: encryptedPassword, // Store encrypted password
|
||||
fromName,
|
||||
fromEmail,
|
||||
},
|
||||
@ -65,7 +79,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
port,
|
||||
secure,
|
||||
username,
|
||||
password,
|
||||
password: encryptedPassword, // Store encrypted password
|
||||
fromName,
|
||||
fromEmail,
|
||||
},
|
||||
@ -74,6 +88,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
return json({ success: "Mail settings saved successfully" });
|
||||
} catch (error) {
|
||||
console.error("Mail settings save error:", error);
|
||||
return json({ error: "Failed to save mail settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -85,13 +100,13 @@ export default function MailSettings() {
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Mail Settings</h1>
|
||||
<div className="max-w-2xl mx-auto p-4 sm:p-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold mb-6">Mail Settings</h1>
|
||||
|
||||
{/* SMTP Configuration Examples */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">Common SMTP Settings</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-blue-800 mb-2">Common SMTP Settings</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-700">Gmail</h4>
|
||||
<p>Host: smtp.gmail.com</p>
|
||||
@ -240,7 +255,7 @@ export default function MailSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex flex-col sm:flex-row sm:space-x-4 space-y-2 sm:space-y-0">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
|
||||
@ -110,15 +110,16 @@ export default function ReportSheet() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Report Sheets</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Report Sheets</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">View grouped reports by location and date</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Sheets Table */}
|
||||
{/* Report Sheets Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<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">
|
||||
@ -216,7 +217,91 @@ export default function ReportSheet() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Sheets Cards - Mobile */}
|
||||
<div className="lg:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{sheets.map((sheet) => (
|
||||
<div key={sheet.id} 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(sheet.date).toLocaleDateString('en-GB')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{sheet.area}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${sheet.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{sheet.status === 'completed' ? (
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{sheet.status.charAt(0).toUpperCase() + sheet.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Dredger:</span>
|
||||
<span className="text-xs text-gray-900">{sheet.dredgerLocation}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Reclamation:</span>
|
||||
<span className="text-xs text-gray-900">{sheet.reclamationLocation}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-gray-500">Shifts:</span>
|
||||
<div className="flex space-x-1">
|
||||
{sheet.dayReport && (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('day')}`}>
|
||||
{getShiftIcon('day')}
|
||||
<span className="ml-1">Day</span>
|
||||
</span>
|
||||
)}
|
||||
{sheet.nightReport && (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getShiftBadge('night')}`}>
|
||||
{getShiftIcon('night')}
|
||||
<span className="ml-1">Night</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sheet.dayReport && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Day Employee:</span>
|
||||
<span className="text-xs text-gray-900">{sheet.dayReport.employee.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{sheet.nightReport && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Night Employee:</span>
|
||||
<span className="text-xs text-gray-900">{sheet.nightReport.employee.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleView(sheet)}
|
||||
className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150"
|
||||
>
|
||||
View Sheet Details
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sheets.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">
|
||||
|
||||
@ -407,20 +407,26 @@ export default function Reports() {
|
||||
// First period
|
||||
if (from1 && to1) {
|
||||
const start1 = parseTime(from1);
|
||||
const end1 = parseTime(to1);
|
||||
let end1 = parseTime(to1);
|
||||
if(end1 < start1)
|
||||
end1 += 24 * 60;
|
||||
totalMinutes += end1 - start1;
|
||||
}
|
||||
|
||||
// Second period
|
||||
if (from2 && to2) {
|
||||
const start2 = parseTime(from2);
|
||||
const end2 = parseTime(to2);
|
||||
let end2 = parseTime(to2);
|
||||
if(end2 < start2)
|
||||
end2 += 24 * 60;
|
||||
totalMinutes += end2 - start2;
|
||||
}
|
||||
|
||||
return formatTime(Math.max(0, totalMinutes));
|
||||
};
|
||||
|
||||
|
||||
|
||||
const calculateStoppageTime = (from: string, to: string) => {
|
||||
if (!from || !to) return "00:00";
|
||||
|
||||
@ -436,7 +442,10 @@ export default function Reports() {
|
||||
};
|
||||
|
||||
const startMinutes = parseTime(from);
|
||||
const endMinutes = parseTime(to);
|
||||
let endMinutes = parseTime(to);
|
||||
if(endMinutes < startMinutes)
|
||||
endMinutes += 24 * 60;
|
||||
|
||||
const totalMinutes = Math.max(0, endMinutes - startMinutes);
|
||||
|
||||
return formatTime(totalMinutes);
|
||||
@ -540,14 +549,14 @@ export default function Reports() {
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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-2xl font-bold text-gray-900">Shifts Management</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Shifts Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Create and manage operational shifts</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/reports/new"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@ -556,107 +565,189 @@ export default function Reports() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Reports Table */}
|
||||
{/* Reports Table - Desktop */}
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Shift Details
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Shift & Area
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Locations
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="hidden md: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">
|
||||
Shift Details
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Shift & Area
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Locations
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">Shift #{report.id}</div>
|
||||
<div className="text-sm text-gray-500">by {report.employee.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">Shift #{report.id}</div>
|
||||
<div className="text-sm text-gray-500">by {report.employee.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(report.shift)}`}>
|
||||
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">{report.area.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
<div> {report.area.name} Dredger</div>
|
||||
<div className="text-gray-500"> {report.dredgerLocation.name} - {report.reclamationLocation.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(report.createdDate).toLocaleDateString('en-GB')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleView(report)}
|
||||
className="text-blue-600 hover:text-blue-900 transition-colors duration-150"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{canEditReport(report) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(report)}
|
||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this report?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reports Cards - Mobile */}
|
||||
<div className="md:hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(report.shift)}`}>
|
||||
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">{report.area.name}</span>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-gray-900">Shift #{report.id}</div>
|
||||
<div className="text-xs text-gray-500">by {report.employee.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
<div> {report.area.name} Dredger</div>
|
||||
<div className="text-gray-500"> {report.dredgerLocation.name} - {report.reclamationLocation.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* {new Date(report.createdDate).toLocaleDateString()} */}
|
||||
{new Date(report.createdDate).toLocaleDateString('en-GB')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(report.shift)}`}>
|
||||
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Area:</span>
|
||||
<span className="text-xs text-gray-900">{report.area.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Dredger:</span>
|
||||
<span className="text-xs text-gray-900">{report.dredgerLocation.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Reclamation:</span>
|
||||
<span className="text-xs text-gray-900">{report.reclamationLocation.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Created:</span>
|
||||
<span className="text-xs text-gray-900">{new Date(report.createdDate).toLocaleDateString('en-GB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={() => handleView(report)}
|
||||
className="w-full text-center px-3 py-2 text-sm text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors duration-150"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{canEditReport(report) && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleView(report)}
|
||||
className="text-blue-600 hover:text-blue-900 transition-colors duration-150"
|
||||
onClick={() => handleEdit(report)}
|
||||
className="flex-1 text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
||||
>
|
||||
View
|
||||
Edit
|
||||
</button>
|
||||
{canEditReport(report) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(report)}
|
||||
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this report?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Form method="post" className="flex-1">
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<input type="hidden" name="id" value={report.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm("Are you sure you want to delete this report?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full text-center px-3 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{reports.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-center py-12 px-4">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@ -670,7 +761,7 @@ export default function Reports() {
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Shifs
|
||||
Create Shifts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -35,10 +35,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireAuthLevel(request, 1);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
|
||||
// Debug logging
|
||||
console.log("Form data received:", Object.fromEntries(formData.entries()));
|
||||
|
||||
|
||||
const shift = formData.get("shift");
|
||||
const areaId = formData.get("areaId");
|
||||
const dredgerLocationId = formData.get("dredgerLocationId");
|
||||
@ -50,8 +50,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Complex JSON fields
|
||||
const reclamationHeightBase = formData.get("reclamationHeightBase");
|
||||
const reclamationHeightExtra = formData.get("reclamationHeightExtra");
|
||||
const pipelineMain = formData.get("pipelineMain");
|
||||
const pipelineExt1 = formData.get("pipelineExt1");
|
||||
const pipelineMain = formData.get("pipelineMain");
|
||||
const pipelineExt1 = formData.get("pipelineExt1");
|
||||
const pipelineReserve = formData.get("pipelineReserve");
|
||||
const pipelineExt2 = formData.get("pipelineExt2");
|
||||
const statsDozers = formData.get("statsDozers");
|
||||
@ -64,7 +64,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
// Validation
|
||||
// console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection });
|
||||
|
||||
|
||||
if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
|
||||
console.log("Shift validation failed:", shift);
|
||||
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
|
||||
@ -114,8 +114,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
dredgerLocationId: parseInt(dredgerLocationId),
|
||||
dredgerLineLength: parseInt(dredgerLineLength),
|
||||
reclamationLocationId: parseInt(reclamationLocationId),
|
||||
shoreConnection: parseInt(shoreConnection),
|
||||
reclamationHeight: {
|
||||
shoreConnection: parseInt(shoreConnection),
|
||||
reclamationHeight: {
|
||||
base: parseInt(reclamationHeightBase as string) || 0,
|
||||
extra: parseInt(reclamationHeightExtra as string) || 0
|
||||
},
|
||||
@ -147,7 +147,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
parseInt(reclamationLocationId),
|
||||
report.createdDate
|
||||
);
|
||||
|
||||
|
||||
// Redirect to reports page with success message
|
||||
return redirect("/reports?success=Report created successfully!");
|
||||
} catch (error) {
|
||||
@ -193,7 +193,7 @@ export default function NewReport() {
|
||||
total: string,
|
||||
reason: string
|
||||
}>>([]);
|
||||
|
||||
|
||||
const [stoppageEntries, setStoppageEntries] = useState<Array<{
|
||||
id: string,
|
||||
from: string,
|
||||
@ -207,8 +207,8 @@ export default function NewReport() {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const totalSteps = 4;
|
||||
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
// Function to update form data
|
||||
const updateFormData = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@ -217,20 +217,20 @@ export default function NewReport() {
|
||||
// Handle form submission - only allow on final step
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
// console.log("Form submit triggered, current step:", currentStep);
|
||||
|
||||
|
||||
if (currentStep !== totalSteps) {
|
||||
console.log("Preventing form submission - not on final step");
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// console.log("Allowing form submission");
|
||||
// console.log("Form being submitted with data:", formData);
|
||||
// console.log("Time sheet entries:", timeSheetEntries);
|
||||
// console.log("Stoppage entries:", stoppageEntries);
|
||||
};
|
||||
|
||||
|
||||
// Helper functions for time calculations
|
||||
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => {
|
||||
if (!from1 || !to1) return "00:00";
|
||||
@ -250,17 +250,28 @@ export default function NewReport() {
|
||||
|
||||
if (from1 && to1) {
|
||||
const start1 = parseTime(from1);
|
||||
const end1 = parseTime(to1);
|
||||
let end1 = parseTime(to1);
|
||||
|
||||
if (end1 < start1) {
|
||||
end1 += 24 * 60;
|
||||
}
|
||||
|
||||
totalMinutes += end1 - start1;
|
||||
}
|
||||
|
||||
if (from2 && to2) {
|
||||
const start2 = parseTime(from2);
|
||||
const end2 = parseTime(to2);
|
||||
let end2 = parseTime(to2);
|
||||
|
||||
|
||||
if (end2 < start2) {
|
||||
end2 += 24 * 60;
|
||||
}
|
||||
totalMinutes += end2 - start2;
|
||||
}
|
||||
|
||||
return formatTime(Math.max(0, totalMinutes));
|
||||
//return formatTime(Math.max(totalMinutes * -1, totalMinutes));
|
||||
};
|
||||
|
||||
const calculateStoppageTime = (from: string, to: string) => {
|
||||
@ -278,12 +289,14 @@ export default function NewReport() {
|
||||
};
|
||||
|
||||
const startMinutes = parseTime(from);
|
||||
const endMinutes = parseTime(to);
|
||||
let endMinutes = parseTime(to);
|
||||
if (endMinutes < startMinutes)
|
||||
endMinutes += 24 * 60;
|
||||
const totalMinutes = Math.max(0, endMinutes - startMinutes);
|
||||
|
||||
return formatTime(totalMinutes);
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
// Time Sheet management
|
||||
const addTimeSheetEntry = () => {
|
||||
const newEntry = {
|
||||
@ -350,8 +363,8 @@ export default function NewReport() {
|
||||
}
|
||||
return entry;
|
||||
}));
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const nextStep = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
@ -384,15 +397,15 @@ export default function NewReport() {
|
||||
<DashboardLayout user={user}>
|
||||
<div className="max-w-full mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create New Shifts</h1>
|
||||
<p className="mt-2 text-gray-600">Fill out the operational shift details step by step</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Create New Shifts</h1>
|
||||
<p className="mt-2 text-sm sm:text-base text-gray-600">Fill out the operational shift details step by step</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/reports"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
@ -403,44 +416,42 @@ export default function NewReport() {
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center justify-between px-2 sm:px-0">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-2 ${
|
||||
step <= currentStep
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
: 'border-gray-300 text-gray-500'
|
||||
}`}>
|
||||
<div className={`flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-full border-2 ${step <= currentStep
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
: 'border-gray-300 text-gray-500'
|
||||
}`}>
|
||||
{step < currentStep ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{step}</span>
|
||||
<span className="text-xs sm:text-sm font-medium">{step}</span>
|
||||
)}
|
||||
</div>
|
||||
{step < totalSteps && (
|
||||
<div className={`flex-1 h-1 mx-4 ${
|
||||
step < currentStep ? 'bg-indigo-600' : 'bg-gray-300'
|
||||
}`} />
|
||||
<div className={`flex-1 h-1 mx-2 sm:mx-4 ${step < currentStep ? 'bg-indigo-600' : 'bg-gray-300'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{getStepTitle(currentStep)}</h2>
|
||||
<p className="text-sm text-gray-500">Step {currentStep} of {totalSteps}</p>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{getStepTitle(currentStep)}</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500">Step {currentStep} of {totalSteps}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Form method="post" onSubmit={handleSubmit} className="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Step 1: Basic Information */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shift <span className="text-red-500">*</span>
|
||||
@ -485,8 +496,8 @@ export default function NewReport() {
|
||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.areaId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label htmlFor="dredgerLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dredger Location <span className="text-red-500">*</span>
|
||||
@ -532,11 +543,11 @@ export default function NewReport() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Step 2: Location & Pipeline Details */}
|
||||
)}
|
||||
{/* Step 2: Location & Pipeline Details */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Reclamation Location <span className="text-red-500">*</span>
|
||||
@ -560,15 +571,15 @@ export default function NewReport() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Reclamation Height</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Reclamation Height</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div><label htmlFor="reclamationHeightBase" className="block text-sm font-medium text-gray-700 mb-2">Base Height (m)</label><input type="number" id="reclamationHeightBase" name="reclamationHeightBase" min="0" value={formData.reclamationHeightBase} onChange={(e) => updateFormData('reclamationHeightBase', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label htmlFor="reclamationHeightExtra" className="block text-sm font-medium text-gray-700 mb-2">Extra Height (m)</label><input type="number" id="reclamationHeightExtra" name="reclamationHeightExtra" min="0" value={formData.reclamationHeightExtra} onChange={(e) => updateFormData('reclamationHeightExtra', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Pipeline Length</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Pipeline Length</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
<div><label htmlFor="pipelineMain" className="block text-sm font-medium text-gray-700 mb-2">Main</label><input type="number" id="pipelineMain" name="pipelineMain" min="0" value={formData.pipelineMain} onChange={(e) => updateFormData('pipelineMain', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label htmlFor="pipelineExt1" className="block text-sm font-medium text-gray-700 mb-2">Extension 1</label><input type="number" id="pipelineExt1" name="pipelineExt1" min="0" value={formData.pipelineExt1} onChange={(e) => updateFormData('pipelineExt1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label htmlFor="pipelineReserve" className="block text-sm font-medium text-gray-700 mb-2">Reserve</label><input type="number" id="pipelineReserve" name="pipelineReserve" min="0" value={formData.pipelineReserve} onChange={(e) => updateFormData('pipelineReserve', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
@ -576,13 +587,13 @@ export default function NewReport() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Step 3: Equipment & Time Sheet */}
|
||||
)}
|
||||
{/* Step 3: Equipment & Time Sheet */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||
<div><label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers</label><input type="number" id="statsDozers" name="statsDozers" min="0" value={formData.statsDozers} onChange={(e) => updateFormData('statsDozers', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">Excavators</label><input type="number" id="statsExc" name="statsExc" min="0" value={formData.statsExc} onChange={(e) => updateFormData('statsExc', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label htmlFor="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">Loaders</label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} onChange={(e) => updateFormData('statsLoaders', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
@ -602,7 +613,11 @@ export default function NewReport() {
|
||||
{timeSheetEntries.map((entry) => (
|
||||
<div key={entry.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-7 gap-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Equipment</label><select value={entry.machine} onChange={(e) => updateTimeSheetEntry(entry.id, 'machine', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select Equipment</option>{equipment.map((item) => (<option key={item.id} value={`${item.model} (${item.number})`}>{item.category} - {item.model} ({item.number})</option>))}</select></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Equipment</label><select value={entry.machine} onChange={(e) => updateTimeSheetEntry(entry.id, 'machine', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select Equipment</option>{equipment.filter((item) => {
|
||||
const machineValue = `${item.model} (${item.number})`;
|
||||
// Show if not selected by any other entry, or if it's the current entry's selection
|
||||
return !timeSheetEntries.some(e => e.id !== entry.id && e.machine === machineValue);
|
||||
}).map((item) => (<option key={item.id} value={`${item.model} (${item.number})`}>{item.category} - {item.model} ({item.number})</option>))}</select></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">From</label><input type="time" value={entry.from1} onChange={(e) => updateTimeSheetEntry(entry.id, 'from1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">To</label><input type="time" value={entry.to1} onChange={(e) => updateTimeSheetEntry(entry.id, 'to1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">From 2</label><input type="time" value={entry.from2} onChange={(e) => updateTimeSheetEntry(entry.id, 'from2', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
||||
@ -619,8 +634,8 @@ export default function NewReport() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Step 4: Stoppages & Notes */}
|
||||
)}
|
||||
{/* Step 4: Stoppages & Notes */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@ -655,8 +670,8 @@ export default function NewReport() {
|
||||
<textarea id="notes" name="notes" rows={4} value={formData.notes} onChange={(e) => updateFormData('notes', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter any additional notes or comments about the operation..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Error Message */}
|
||||
)}
|
||||
{/* Error Message */}
|
||||
{actionData?.errors?.form && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex">
|
||||
@ -672,18 +687,18 @@ export default function NewReport() {
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-between">
|
||||
<button type="button" onClick={prevStep} disabled={currentStep === 1} className={`inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium ${currentStep === 1 ? 'text-gray-400 bg-gray-100 cursor-not-allowed' : 'text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'}`}>
|
||||
<div className="px-4 sm:px-6 py-4 bg-gray-50 border-t border-gray-200 flex flex-col sm:flex-row sm:justify-between space-y-3 sm:space-y-0">
|
||||
<button type="button" onClick={prevStep} disabled={currentStep === 1} className={`inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium ${currentStep === 1 ? 'text-gray-400 bg-gray-100 cursor-not-allowed' : '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="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>Previous
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
{currentStep < totalSteps ? (
|
||||
<button type="button" onClick={(e) => nextStep(e)} className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<button type="button" onClick={(e) => nextStep(e)} className="flex-1 sm:flex-none inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Next<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
|
||||
</button>
|
||||
) : (
|
||||
<button type="submit" disabled={isSubmitting} className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<button type="submit" disabled={isSubmitting} className="flex-1 sm:flex-none inline-flex items-center justify-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
@ -704,7 +719,7 @@ export default function NewReport() {
|
||||
{/* Hidden inputs for dynamic data */}
|
||||
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
|
||||
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
|
||||
|
||||
|
||||
{/* Hidden inputs for form data from all steps */}
|
||||
{currentStep !== 1 && (
|
||||
<>
|
||||
|
||||
@ -45,15 +45,15 @@ export default function SignIn() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-6 sm:space-y-8">
|
||||
<div className="text-center">
|
||||
<img
|
||||
className="mx-auto h-28 w-auto"
|
||||
className="mx-auto h-20 sm:h-28 w-auto"
|
||||
src="/clogo-sm.png"
|
||||
alt="Phosphat Report"
|
||||
/>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-4 sm:mt-6 text-2xl sm:text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
|
||||
@ -81,15 +81,15 @@ export default function SignUp() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-6 sm:space-y-8">
|
||||
<div className="text-center">
|
||||
<img
|
||||
className="mx-auto h-24 w-auto"
|
||||
className="mx-auto h-20 sm:h-24 w-auto"
|
||||
src="/clogo-sm.png"
|
||||
alt="Phosphat Report"
|
||||
/>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-4 sm:mt-6 text-2xl sm:text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
|
||||
@ -43,8 +43,8 @@ export default function TestEmail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Test Email</h1>
|
||||
<div className="max-w-2xl mx-auto p-4 sm:p-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold mb-6">Test Email</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Use this form to test your email configuration. Only users with auth level 3 can access this feature.
|
||||
</p>
|
||||
|
||||
143
app/routes/test-encryption.tsx
Normal file
143
app/routes/test-encryption.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { requireAuthLevel } from "~/utils/auth.server";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { testEncryption, encrypt, decrypt } from "~/utils/encryption.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Require auth level 3 to access encryption test
|
||||
const user = await requireAuthLevel(request, 3);
|
||||
|
||||
// Test encryption with sample data
|
||||
const testResults = testEncryption("sample-smtp-password-123");
|
||||
|
||||
// Test with different types of data
|
||||
const tests = [
|
||||
{ name: "Simple Password", data: "mypassword123" },
|
||||
{ name: "Complex Password", data: "P@ssw0rd!@#$%^&*()" },
|
||||
{ name: "Email Password", data: "smtp.gmail.app.password" },
|
||||
{ name: "Special Characters", data: "test!@#$%^&*()_+-=[]{}|;:,.<>?" }
|
||||
];
|
||||
|
||||
const testResults2 = tests.map(test => {
|
||||
try {
|
||||
const encrypted = encrypt(test.data);
|
||||
const decrypted = decrypt(encrypted);
|
||||
return {
|
||||
...test,
|
||||
encrypted,
|
||||
decrypted,
|
||||
success: decrypted === test.data,
|
||||
encryptedLength: encrypted.length
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...test,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return json({
|
||||
user,
|
||||
basicTest: testResults,
|
||||
detailedTests: testResults2
|
||||
});
|
||||
}
|
||||
|
||||
export default function TestEncryption() {
|
||||
const { user, basicTest, detailedTests } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Encryption Test Results</h1>
|
||||
|
||||
{/* Basic Test */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Basic Encryption Test</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium w-20">Status:</span>
|
||||
<span className={`px-2 py-1 rounded text-sm ${
|
||||
basicTest.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{basicTest.success ? 'PASSED' : 'FAILED'}
|
||||
</span>
|
||||
</div>
|
||||
{basicTest.success ? (
|
||||
<>
|
||||
<div><span className="font-medium">Original:</span> {basicTest.original}</div>
|
||||
<div><span className="font-medium">Encrypted:</span> <code className="bg-gray-100 px-2 py-1 rounded text-sm">{basicTest.encrypted}</code></div>
|
||||
<div><span className="font-medium">Decrypted:</span> {basicTest.decrypted}</div>
|
||||
<div><span className="font-medium">Valid:</span> {basicTest.isValid ? '✅' : '❌'}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-600">Error: {basicTest.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Tests */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Detailed Encryption Tests</h2>
|
||||
<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">Test Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Original</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Encrypted Length</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{detailedTests.map((test, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{test.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
test.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{test.success ? 'PASS' : 'FAIL'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
||||
{test.data.length > 20 ? test.data.substring(0, 20) + '...' : test.data}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{test.encryptedLength || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{test.success ? '✅' : '❌'}
|
||||
{test.error && <div className="text-red-500 text-xs mt-1">{test.error}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Notes */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">Security Notes</h3>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
<li>• Passwords are encrypted using AES-256-CBC algorithm</li>
|
||||
<li>• Each encryption uses a random IV (Initialization Vector)</li>
|
||||
<li>• Encrypted data format: IV:EncryptedData</li>
|
||||
<li>• Encryption key should be set via ENCRYPTION_KEY environment variable</li>
|
||||
<li>• This test page should only be accessible to Super Admins</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
152
app/utils/encryption.server.ts
Normal file
152
app/utils/encryption.server.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Get encryption key from environment or generate a default one
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'phosphat-report-default-key-32b'; // Must be 32 characters
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const IV_LENGTH = 16; // For AES, this is always 16
|
||||
|
||||
// Ensure the key is exactly 32 bytes
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = ENCRYPTION_KEY.padEnd(32, '0').substring(0, 32);
|
||||
return Buffer.from(key, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a plain text string
|
||||
* @param text - The plain text to encrypt
|
||||
* @returns Encrypted string in format: iv:encryptedData
|
||||
*/
|
||||
export function encrypt(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
try {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipher(ALGORITHM, key);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
// Return iv and encrypted data separated by ':'
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an encrypted string
|
||||
* @param encryptedText - The encrypted text in format: iv:encryptedData
|
||||
* @returns Decrypted plain text string
|
||||
*/
|
||||
export function decrypt(encryptedText: string): string {
|
||||
if (!encryptedText) return '';
|
||||
|
||||
try {
|
||||
const key = getEncryptionKey();
|
||||
const textParts = encryptedText.split(':');
|
||||
|
||||
if (textParts.length !== 2) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(textParts[0], 'hex');
|
||||
const encryptedData = textParts[1];
|
||||
|
||||
const decipher = crypto.createDecipher(ALGORITHM, key);
|
||||
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts sensitive mail settings
|
||||
* @param settings - Mail settings object
|
||||
* @returns Mail settings with encrypted password
|
||||
*/
|
||||
export function encryptMailSettings(settings: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
}) {
|
||||
return {
|
||||
...settings,
|
||||
password: encrypt(settings.password)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts sensitive mail settings
|
||||
* @param settings - Mail settings object with encrypted password
|
||||
* @returns Mail settings with decrypted password
|
||||
*/
|
||||
export function decryptMailSettings(settings: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
}) {
|
||||
return {
|
||||
...settings,
|
||||
password: decrypt(settings.password)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is encrypted (contains ':' separator)
|
||||
* @param text - Text to validate
|
||||
* @returns True if text appears to be encrypted
|
||||
*/
|
||||
export function isEncrypted(text: string): boolean {
|
||||
return text.includes(':') && text.split(':').length === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely encrypts a password only if it's not already encrypted
|
||||
* @param password - Password to encrypt
|
||||
* @returns Encrypted password
|
||||
*/
|
||||
export function safeEncryptPassword(password: string): string {
|
||||
if (isEncrypted(password)) {
|
||||
return password; // Already encrypted
|
||||
}
|
||||
return encrypt(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption/decryption functionality
|
||||
* @param testText - Text to test with
|
||||
* @returns Test results
|
||||
*/
|
||||
export function testEncryption(testText: string = 'test-password-123') {
|
||||
try {
|
||||
const encrypted = encrypt(testText);
|
||||
const decrypted = decrypt(encrypted);
|
||||
|
||||
return {
|
||||
success: decrypted === testText,
|
||||
original: testText,
|
||||
encrypted: encrypted,
|
||||
decrypted: decrypted,
|
||||
isValid: decrypted === testText
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -33,61 +33,203 @@ interface ReportData {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export async function exportReportToExcel(report: ReportData) {
|
||||
interface ReportSheet {
|
||||
id: string;
|
||||
date: string;
|
||||
area: string;
|
||||
dredgerLocation: string;
|
||||
reclamationLocation: string;
|
||||
dayReport?: ReportData;
|
||||
nightReport?: ReportData;
|
||||
}
|
||||
|
||||
// Function to add logo images to worksheet
|
||||
async function addLogosToWorksheet(worksheet: ExcelJS.Worksheet, currentRow: number) {
|
||||
try {
|
||||
// Try to add company logo (left side)
|
||||
try {
|
||||
const logoResponse = await fetch('/logo03.png');
|
||||
if (logoResponse.ok) {
|
||||
const logoBuffer = await logoResponse.arrayBuffer();
|
||||
const logoImageId = worksheet.workbook.addImage({
|
||||
buffer: logoBuffer,
|
||||
extension: 'png',
|
||||
});
|
||||
worksheet.addImage(logoImageId, {
|
||||
tl: { col: 0, row: currentRow - 1 },
|
||||
ext: { width: 100, height: 60 }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Company logo not found, using text placeholder');
|
||||
}
|
||||
|
||||
// Try to add Arab Potash logo (right side)
|
||||
try {
|
||||
const arabPotashResponse = await fetch('/logo-light.png');
|
||||
if (arabPotashResponse.ok) {
|
||||
const arabPotashBuffer = await arabPotashResponse.arrayBuffer();
|
||||
const arabPotashImageId = worksheet.workbook.addImage({
|
||||
buffer: arabPotashBuffer,
|
||||
extension: 'png',
|
||||
});
|
||||
worksheet.addImage(arabPotashImageId, {
|
||||
tl: { col: 3, row: currentRow - 1 },
|
||||
ext: { width: 100, height: 60 }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Arab Potash logo not found, using text placeholder');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error loading images:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportReportToExcel(reportOrSheet: ReportData | ReportSheet) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('Report');
|
||||
|
||||
// Set column widths to match the professional layout from the reference file
|
||||
// Set RTL (Right-to-Left) layout
|
||||
worksheet.views = [{ rightToLeft: false }];
|
||||
|
||||
// Set column widths to match the report view layout exactly
|
||||
worksheet.columns = [
|
||||
{ width: 30 }, // A - Labels/Machine names
|
||||
{ width: 20 }, // B - Data/Values
|
||||
{ width: 20 }, // C - Labels/Data
|
||||
{ width: 20 }, // D - Data/Values
|
||||
{ width: 15 }, // E - Pipeline data
|
||||
{ width: 15 }, // F - Pipeline data
|
||||
{ width: 25 } // G - Reason/Notes
|
||||
{ width: 25 }, // A - First column (25% width)
|
||||
{ width: 25 }, // B - Second column (25% width)
|
||||
{ width: 25 }, // C - Third column (25% width)
|
||||
{ width: 25 }, // D - Fourth column (25% width)
|
||||
];
|
||||
|
||||
let currentRow = 1;
|
||||
|
||||
// 1. HEADER SECTION - Professional layout matching reference file
|
||||
// Main header with company info
|
||||
worksheet.mergeCells(`A${currentRow}:E${currentRow + 2}`);
|
||||
const headerCell = worksheet.getCell(`A${currentRow}`);
|
||||
headerCell.value = 'Reclamation Work Diary';
|
||||
headerCell.style = {
|
||||
// Check if this is a ReportSheet (combined day/night) or single Report
|
||||
const isReportSheet = 'dayReport' in reportOrSheet || 'nightReport' in reportOrSheet;
|
||||
|
||||
if (isReportSheet) {
|
||||
await exportReportSheet(worksheet, reportOrSheet as ReportSheet, currentRow);
|
||||
} else {
|
||||
await exportSingleReport(worksheet, reportOrSheet as ReportData, currentRow);
|
||||
}
|
||||
|
||||
// Generate and save file
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
let fileName: string;
|
||||
if (isReportSheet) {
|
||||
const sheet = reportOrSheet as ReportSheet;
|
||||
fileName = `ReportSheet_${sheet.id}_${new Date(sheet.date).toLocaleDateString('en-GB').replace(/\//g, '-')}.xlsx`;
|
||||
} else {
|
||||
const report = reportOrSheet as ReportData;
|
||||
fileName = `Report_${report.id}_${new Date(report.createdDate).toLocaleDateString('en-GB').replace(/\//g, '-')}.xlsx`;
|
||||
}
|
||||
|
||||
FileSaver.saveAs(blob, fileName);
|
||||
}
|
||||
|
||||
async function exportReportSheet(worksheet: ExcelJS.Worksheet, sheet: ReportSheet, startRow: number) {
|
||||
let currentRow = startRow;
|
||||
|
||||
// Add logos
|
||||
await addLogosToWorksheet(worksheet, currentRow);
|
||||
|
||||
// 1. HEADER SECTION
|
||||
currentRow = await addReportSheetHeader(worksheet, sheet, currentRow);
|
||||
|
||||
// 2. REPORT INFO SECTION
|
||||
currentRow = await addReportSheetInfo(worksheet, sheet, currentRow);
|
||||
|
||||
// 3. DREDGER SECTION
|
||||
currentRow = await addDredgerSection(worksheet, sheet.area, currentRow);
|
||||
|
||||
// 4. LOCATION DATA SECTION
|
||||
const report = sheet.dayReport || sheet.nightReport;
|
||||
if (report) {
|
||||
currentRow = await addLocationData(worksheet, report, currentRow);
|
||||
currentRow = await addPipelineLength(worksheet, report, currentRow);
|
||||
}
|
||||
|
||||
// 5. DAY SHIFT SECTION
|
||||
if (sheet.dayReport) {
|
||||
currentRow = await addShiftSection(worksheet, sheet.dayReport, 'Day', currentRow);
|
||||
}
|
||||
|
||||
// 6. NIGHT SHIFT SECTION
|
||||
if (sheet.nightReport) {
|
||||
currentRow = await addShiftSection(worksheet, sheet.nightReport, 'Night', currentRow);
|
||||
}
|
||||
|
||||
// 7. FOOTER
|
||||
await addFooter(worksheet, currentRow);
|
||||
}
|
||||
|
||||
async function exportSingleReport(worksheet: ExcelJS.Worksheet, report: ReportData, startRow: number) {
|
||||
let currentRow = startRow;
|
||||
|
||||
// Add logos
|
||||
await addLogosToWorksheet(worksheet, currentRow);
|
||||
|
||||
// 1. HEADER SECTION
|
||||
currentRow = await addSingleReportHeader(worksheet, report, currentRow);
|
||||
|
||||
// 2. REPORT INFO SECTION
|
||||
currentRow = await addSingleReportInfo(worksheet, report, currentRow);
|
||||
|
||||
// 3. DREDGER SECTION
|
||||
currentRow = await addDredgerSection(worksheet, report.area, currentRow);
|
||||
|
||||
// 4. LOCATION DATA SECTION
|
||||
currentRow = await addLocationData(worksheet, report, currentRow);
|
||||
|
||||
// 5. PIPELINE LENGTH SECTION
|
||||
currentRow = await addPipelineLength(worksheet, report, currentRow);
|
||||
|
||||
// 6. SHIFT SECTION
|
||||
currentRow = await addShiftSection(worksheet, report, report.shift, currentRow);
|
||||
|
||||
// 7. FOOTER
|
||||
await addFooter(worksheet, currentRow);
|
||||
}
|
||||
|
||||
// Helper functions for building Excel sections
|
||||
async function addReportSheetHeader(worksheet: ExcelJS.Worksheet, sheet: ReportSheet, currentRow: number): Promise<number> {
|
||||
// Create the 3-column header layout
|
||||
worksheet.mergeCells(`A${currentRow}:A${currentRow + 2}`);
|
||||
const logoLeftCell = worksheet.getCell(`A${currentRow}`);
|
||||
logoLeftCell.value = '';
|
||||
logoLeftCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
// Middle section - main header
|
||||
worksheet.mergeCells(`B${currentRow}:C${currentRow}`);
|
||||
const headerMainCell = worksheet.getCell(`B${currentRow}`);
|
||||
headerMainCell.value = 'Reclamation Work Diary';
|
||||
headerMainCell.style = {
|
||||
font: { name: 'Arial', size: 16, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thin', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
// Logo area
|
||||
worksheet.mergeCells(`F${currentRow}:G${currentRow + 2}`);
|
||||
const logoCell = worksheet.getCell(`F${currentRow}`);
|
||||
logoCell.value = 'Arab Potash\nCompany Logo';
|
||||
logoCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle', wrapText: true },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
// Sub-header info
|
||||
const qfCell = worksheet.getCell(`A${currentRow + 1}`);
|
||||
// QF code
|
||||
worksheet.mergeCells(`B${currentRow + 1}:C${currentRow + 1}`);
|
||||
const qfCell = worksheet.getCell(`B${currentRow + 1}`);
|
||||
qfCell.value = 'QF-3.6.1-08';
|
||||
qfCell.style = {
|
||||
font: { name: 'Arial', size: 10 },
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thin', color: { argb: '000000' } },
|
||||
@ -97,10 +239,12 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
const revCell = worksheet.getCell(`A${currentRow + 2}`);
|
||||
// Rev code
|
||||
worksheet.mergeCells(`B${currentRow + 2}:C${currentRow + 2}`);
|
||||
const revCell = worksheet.getCell(`B${currentRow + 2}`);
|
||||
revCell.value = 'Rev. 1.0';
|
||||
revCell.style = {
|
||||
font: { name: 'Arial', size: 10 },
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thin', color: { argb: '000000' } },
|
||||
@ -110,33 +254,90 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
currentRow += 4; // Skip to next section
|
||||
|
||||
// 2. REPORT INFO SECTION - Professional table layout
|
||||
const infoRowCells = [
|
||||
{ col: 'A', label: 'Date:', value: new Date(report.createdDate).toLocaleDateString('en-GB') },
|
||||
{ col: 'C', label: 'Report No.', value: report.id.toString() }
|
||||
];
|
||||
|
||||
// Create bordered info section
|
||||
['A', 'B', 'C', 'D', 'E', 'F', 'G'].forEach(col => {
|
||||
const cell = worksheet.getCell(`${col}${currentRow}`);
|
||||
cell.style = {
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
});
|
||||
// Right logo
|
||||
worksheet.mergeCells(`D${currentRow}:D${currentRow + 2}`);
|
||||
const logoRightCell = worksheet.getCell(`D${currentRow}`);
|
||||
logoRightCell.value = '';
|
||||
logoRightCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
return currentRow + 4;
|
||||
}
|
||||
|
||||
async function addSingleReportHeader(worksheet: ExcelJS.Worksheet, report: ReportData, currentRow: number): Promise<number> {
|
||||
return await addReportSheetHeader(worksheet, { area: report.area.name } as ReportSheet, currentRow);
|
||||
}
|
||||
|
||||
async function addReportSheetInfo(worksheet: ExcelJS.Worksheet, sheet: ReportSheet, currentRow: number): Promise<number> {
|
||||
const dateCell = worksheet.getCell(`A${currentRow}`);
|
||||
dateCell.value = 'Date:';
|
||||
dateCell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true },
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
const dateValueCell = worksheet.getCell(`B${currentRow}`);
|
||||
dateValueCell.value = new Date(sheet.date).toLocaleDateString('en-GB');
|
||||
dateValueCell.style = {
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
const reportNoCell = worksheet.getCell(`C${currentRow}`);
|
||||
reportNoCell.value = 'Report No.';
|
||||
reportNoCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
const reportNoValueCell = worksheet.getCell(`D${currentRow}`);
|
||||
reportNoValueCell.value = sheet.id.toString();
|
||||
reportNoValueCell.style = {
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
return currentRow + 2;
|
||||
}
|
||||
|
||||
async function addSingleReportInfo(worksheet: ExcelJS.Worksheet, report: ReportData, currentRow: number): Promise<number> {
|
||||
const dateCell = worksheet.getCell(`A${currentRow}`);
|
||||
dateCell.value = 'Date:';
|
||||
dateCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -148,22 +349,8 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
const dateValueCell = worksheet.getCell(`B${currentRow}`);
|
||||
dateValueCell.value = new Date(report.createdDate).toLocaleDateString('en-GB');
|
||||
dateValueCell.style = {
|
||||
font: { name: 'Arial', size: 11 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
const reportNoCell = worksheet.getCell(`E${currentRow}`);
|
||||
reportNoCell.value = 'Report No.';
|
||||
reportNoCell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true },
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -172,11 +359,11 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
const reportNoValueCell = worksheet.getCell(`F${currentRow}`);
|
||||
reportNoValueCell.value = report.id.toString();
|
||||
reportNoValueCell.style = {
|
||||
font: { name: 'Arial', size: 11 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
const shiftNoCell = worksheet.getCell(`C${currentRow}`);
|
||||
shiftNoCell.value = 'Shift No.';
|
||||
shiftNoCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -185,34 +372,55 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
currentRow += 2; // Skip empty row
|
||||
const shiftNoValueCell = worksheet.getCell(`D${currentRow}`);
|
||||
shiftNoValueCell.value = report.id.toString();
|
||||
shiftNoValueCell.style = {
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
// 3. DREDGER SECTION - Professional centered title
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||
return currentRow + 2;
|
||||
}
|
||||
|
||||
async function addDredgerSection(worksheet: ExcelJS.Worksheet, area: { name: string } | string, currentRow: number): Promise<number> {
|
||||
const areaName = typeof area === 'string' ? area : area.name;
|
||||
|
||||
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
|
||||
const dredgerCell = worksheet.getCell(`A${currentRow}`);
|
||||
dredgerCell.value = `${report.area.name} Dredger`;
|
||||
dredgerCell.value = `${areaName} Dredger`;
|
||||
dredgerCell.style = {
|
||||
font: { name: 'Arial', size: 18, bold: true, underline: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } }
|
||||
alignment: { horizontal: 'center', vertical: 'middle' }
|
||||
};
|
||||
|
||||
currentRow += 2; // Skip empty row
|
||||
|
||||
// 4. LOCATION DATA SECTION - Professional table with green headers
|
||||
const locationRows = [
|
||||
['Dredger Location', report.dredgerLocation.name, '', 'Dredger Line Length', report.dredgerLineLength.toString()],
|
||||
['Reclamation Location', report.reclamationLocation.name, '', 'Shore Connection', report.shoreConnection.toString()],
|
||||
['Reclamation Height', `${report.reclamationHeight?.base || 0}m - ${(report.reclamationHeight?.base || 0) + (report.reclamationHeight?.extra || 0)}m`, '', '', '']
|
||||
return currentRow + 2;
|
||||
}
|
||||
|
||||
async function addLocationData(worksheet: ExcelJS.Worksheet, report: ReportData, currentRow: number): Promise<number> {
|
||||
const locationData = [
|
||||
['Dredger Location', report.dredgerLocation.name, 'Dredger Line Length', report.dredgerLineLength.toString()],
|
||||
['Reclamation Location', report.reclamationLocation.name, 'Shore Connection', report.shoreConnection.toString()],
|
||||
['Reclamation Height', `${report.reclamationHeight?.base || 0}m - ${(report.reclamationHeight?.base || 0) + (report.reclamationHeight?.extra || 0)}m`, '', '']
|
||||
];
|
||||
|
||||
locationRows.forEach((rowData, index) => {
|
||||
locationData.forEach((rowData, index) => {
|
||||
const row = currentRow + index;
|
||||
|
||||
// Apply styling to all cells in the row first
|
||||
for (let col = 1; col <= 7; col++) {
|
||||
const cell = worksheet.getCell(row, col);
|
||||
rowData.forEach((cellValue, colIndex) => {
|
||||
const cell = worksheet.getCell(row, colIndex + 1);
|
||||
cell.value = cellValue;
|
||||
|
||||
const isGreenHeader = (colIndex === 0 || colIndex === 2) && cellValue !== '';
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: isGreenHeader, color: isGreenHeader ? { argb: 'FFFFFF' } : { argb: '000000' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: isGreenHeader ? { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } } : { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -220,52 +428,31 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
rowData.forEach((cellValue, colIndex) => {
|
||||
if (cellValue !== '') {
|
||||
const cell = worksheet.getCell(row, colIndex + 1);
|
||||
cell.value = cellValue;
|
||||
|
||||
const isGreenHeader = (colIndex === 0 || colIndex === 3);
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: isGreenHeader, color: isGreenHeader ? { argb: 'FFFFFF' } : { argb: '000000' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: isGreenHeader ? { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } } : { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Merge cells for better layout
|
||||
if (index === 0) {
|
||||
worksheet.mergeCells(`B${row}:C${row}`); // Dredger Location value
|
||||
worksheet.mergeCells(`E${row}:G${row}`); // Dredger Line Length value
|
||||
} else if (index === 1) {
|
||||
worksheet.mergeCells(`B${row}:C${row}`); // Reclamation Location value
|
||||
worksheet.mergeCells(`E${row}:G${row}`); // Shore Connection value
|
||||
} else if (index === 2) {
|
||||
worksheet.mergeCells(`B${row}:G${row}`); // Reclamation Height spans all remaining columns
|
||||
}
|
||||
});
|
||||
|
||||
currentRow += 4; // Skip empty row
|
||||
return currentRow + 4;
|
||||
}
|
||||
|
||||
async function addPipelineLength(worksheet: ExcelJS.Worksheet, report: ReportData, currentRow: number): Promise<number> {
|
||||
// Expand to 7 columns for pipeline section
|
||||
worksheet.columns = [
|
||||
{ width: 30 }, // A - Pipeline header
|
||||
{ width: 15 }, // B - Main
|
||||
{ width: 15 }, // C - extension
|
||||
{ width: 15 }, // D - total
|
||||
{ width: 15 }, // E - Reserve
|
||||
{ width: 15 }, // F - extension
|
||||
{ width: 15 } // G - total
|
||||
];
|
||||
|
||||
// 5. PIPELINE LENGTH SECTION - Professional table with green headers
|
||||
const pipelineHeaderRow = currentRow;
|
||||
|
||||
// First row - main header with rowspan
|
||||
const mainHeaderCell = worksheet.getCell(pipelineHeaderRow, 1);
|
||||
mainHeaderCell.value = 'Pipeline Length "from Shore Connection"';
|
||||
mainHeaderCell.style = {
|
||||
// Pipeline header row
|
||||
const pipelineHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||
pipelineHeaderCell.value = 'Pipeline Length "from Shore Connection"';
|
||||
pipelineHeaderCell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -274,15 +461,15 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
// Sub-headers
|
||||
// Pipeline sub-headers
|
||||
const pipelineSubHeaders = ['Main', 'extension', 'total', 'Reserve', 'extension', 'total'];
|
||||
pipelineSubHeaders.forEach((header, colIndex) => {
|
||||
const cell = worksheet.getCell(pipelineHeaderRow, colIndex + 2);
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 2);
|
||||
cell.value = header;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 10, bold: true, color: { argb: 'FFFFFF' } },
|
||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -292,9 +479,20 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
};
|
||||
});
|
||||
|
||||
// Data row
|
||||
const pipelineDataRow = currentRow + 1;
|
||||
const pipelineData = ['',
|
||||
// Pipeline data row
|
||||
currentRow++;
|
||||
const pipelineDataCell = worksheet.getCell(`A${currentRow}`);
|
||||
pipelineDataCell.value = '';
|
||||
pipelineDataCell.style = {
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
|
||||
const pipelineData = [
|
||||
(report.pipelineLength?.main || 0).toString(),
|
||||
(report.pipelineLength?.ext1 || 0).toString(),
|
||||
((report.pipelineLength?.main || 0) + (report.pipelineLength?.ext1 || 0)).toString(),
|
||||
@ -304,12 +502,11 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
];
|
||||
|
||||
pipelineData.forEach((data, colIndex) => {
|
||||
const cell = worksheet.getCell(pipelineDataRow, colIndex + 1);
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 2);
|
||||
cell.value = data;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -319,16 +516,18 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
};
|
||||
});
|
||||
|
||||
currentRow += 4; // Skip empty row
|
||||
|
||||
// 6. SHIFT HEADER SECTION - Professional full-width header
|
||||
return currentRow + 2;
|
||||
}
|
||||
|
||||
async function addShiftSection(worksheet: ExcelJS.Worksheet, report: ReportData, shiftName: string, currentRow: number): Promise<number> {
|
||||
// SHIFT HEADER SECTION
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||
const shiftCell = worksheet.getCell(`A${currentRow}`);
|
||||
shiftCell.value = `${report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift`;
|
||||
shiftCell.value = `${shiftName.charAt(0).toUpperCase() + shiftName.slice(1)} Shift`;
|
||||
shiftCell.style = {
|
||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -337,33 +536,26 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
currentRow += 2; // Skip empty row
|
||||
currentRow += 1;
|
||||
|
||||
// 7. EQUIPMENT STATS SECTION - Professional table with green headers
|
||||
// EQUIPMENT STATS SECTION
|
||||
const equipmentHeaders = ['Dozers', 'Exc.', 'Loader', 'Foreman', 'Laborer'];
|
||||
|
||||
// Apply borders to all cells in the equipment section
|
||||
for (let col = 1; col <= 7; col++) {
|
||||
for (let row = currentRow; row <= currentRow + 1; row++) {
|
||||
const cell = worksheet.getCell(row, col);
|
||||
cell.style = {
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Adjust columns back to 5 for equipment section
|
||||
worksheet.columns = [
|
||||
{ width: 20 }, // A - Dozers
|
||||
{ width: 20 }, // B - Exc.
|
||||
{ width: 20 }, // C - Loader
|
||||
{ width: 20 }, // D - Foreman
|
||||
{ width: 20 } // E - Laborer
|
||||
];
|
||||
|
||||
equipmentHeaders.forEach((header, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = header;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
||||
font: { name: 'Arial', size: 11, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -373,6 +565,7 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
};
|
||||
});
|
||||
|
||||
currentRow++;
|
||||
const equipmentData = [
|
||||
(report.stats?.Dozers || 0).toString(),
|
||||
(report.stats?.Exc || 0).toString(),
|
||||
@ -382,12 +575,11 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
];
|
||||
|
||||
equipmentData.forEach((data, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow + 1, colIndex + 1);
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = data;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -397,18 +589,51 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
};
|
||||
});
|
||||
|
||||
currentRow += 4; // Skip empty row
|
||||
currentRow += 2;
|
||||
|
||||
// 8. TIME SHEET SECTION - Professional table
|
||||
const createProfessionalTable = (headers: string[], data: any[][], startRow: number) => {
|
||||
// Headers
|
||||
headers.forEach((header, colIndex) => {
|
||||
const cell = worksheet.getCell(startRow, colIndex + 1);
|
||||
cell.value = header;
|
||||
// TIME SHEET SECTION
|
||||
// Expand to 7 columns for time sheet
|
||||
worksheet.columns = [
|
||||
{ width: 20 }, // A - Time Sheet
|
||||
{ width: 15 }, // B - From
|
||||
{ width: 15 }, // C - To
|
||||
{ width: 15 }, // D - From
|
||||
{ width: 15 }, // E - To
|
||||
{ width: 15 }, // F - Total
|
||||
{ width: 30 } // G - Reason
|
||||
];
|
||||
|
||||
const timeSheetHeaders = ['Time Sheet', 'From', 'To', 'From', 'To', 'Total', 'Reason'];
|
||||
|
||||
timeSheetHeaders.forEach((header, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = header;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
currentRow++;
|
||||
|
||||
const timeSheetData = Array.isArray(report.timeSheet) && report.timeSheet.length > 0
|
||||
? report.timeSheet
|
||||
: [{ machine: 'No time sheet entries', from1: '', to1: '', from2: '', to2: '', total: '', reason: '' }];
|
||||
|
||||
timeSheetData.forEach((entry) => {
|
||||
const rowData = [entry.machine, entry.from1, entry.to1, entry.from2, entry.to2, entry.total, entry.reason];
|
||||
rowData.forEach((data, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = data;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
font: { name: 'Arial', size: 10, bold: colIndex === 0 },
|
||||
alignment: { horizontal: colIndex === 0 ? 'left' : 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -417,47 +642,19 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Data rows
|
||||
data.forEach((rowData, rowIndex) => {
|
||||
const row = startRow + rowIndex + 1;
|
||||
rowData.forEach((cellData, colIndex) => {
|
||||
const cell = worksheet.getCell(row, colIndex + 1);
|
||||
cell.value = cellData;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 10, bold: colIndex === 0 },
|
||||
alignment: { horizontal: colIndex === 0 ? 'left' : 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return startRow + data.length + 1;
|
||||
};
|
||||
currentRow++;
|
||||
});
|
||||
|
||||
const timeSheetHeaders = ['Time Sheet', 'From', 'To', 'From', 'To', 'Total', 'Reason'];
|
||||
const timeSheetData = Array.isArray(report.timeSheet) && report.timeSheet.length > 0
|
||||
? report.timeSheet.map(entry => [entry.machine, entry.from1, entry.to1, entry.from2, entry.to2, entry.total, entry.reason])
|
||||
: [['No time sheet entries', '', '', '', '', '', '']];
|
||||
currentRow += 1;
|
||||
|
||||
currentRow = createProfessionalTable(timeSheetHeaders, timeSheetData, currentRow);
|
||||
|
||||
currentRow += 2; // Skip empty row
|
||||
|
||||
// 9. STOPPAGES SECTION - Professional section with header
|
||||
// STOPPAGES SECTION
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||
const stoppagesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||
stoppagesHeaderCell.value = 'Dredger Stoppages';
|
||||
stoppagesHeaderCell.style = {
|
||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -468,23 +665,68 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
|
||||
currentRow++;
|
||||
|
||||
const stoppagesHeaders = ['From', 'To', 'Total', 'Reason', 'Responsible', 'Notes', ''];
|
||||
// Adjust columns for stoppages (6 columns)
|
||||
worksheet.columns = [
|
||||
{ width: 15 }, // A - From
|
||||
{ width: 15 }, // B - To
|
||||
{ width: 15 }, // C - Total
|
||||
{ width: 25 }, // D - Reason
|
||||
{ width: 20 }, // E - Responsible
|
||||
{ width: 30 } // F - Notes
|
||||
];
|
||||
|
||||
const stoppagesHeaders = ['From', 'To', 'Total', 'Reason', 'Responsible', 'Notes'];
|
||||
|
||||
stoppagesHeaders.forEach((header, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = header;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 11, bold: true },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
currentRow++;
|
||||
|
||||
const stoppagesData = Array.isArray(report.stoppages) && report.stoppages.length > 0
|
||||
? report.stoppages.map(entry => [entry.from, entry.to, entry.total, entry.reason, entry.responsible, entry.note, ''])
|
||||
: [['No stoppages recorded', '', '', '', '', '', '']];
|
||||
? report.stoppages
|
||||
: [{ from: 'No stoppages recorded', to: '', total: '', reason: '', responsible: '', note: '' }];
|
||||
|
||||
currentRow = createProfessionalTable(stoppagesHeaders, stoppagesData, currentRow);
|
||||
stoppagesData.forEach((entry) => {
|
||||
const rowData = [entry.from, entry.to, entry.total, entry.reason, entry.responsible, entry.note];
|
||||
rowData.forEach((data, colIndex) => {
|
||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||
cell.value = data;
|
||||
cell.style = {
|
||||
font: { name: 'Arial', size: 10 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
||||
right: { style: 'thick', color: { argb: '000000' } }
|
||||
}
|
||||
};
|
||||
});
|
||||
currentRow++;
|
||||
});
|
||||
|
||||
currentRow += 2; // Skip empty row
|
||||
currentRow += 1;
|
||||
|
||||
// 10. NOTES SECTION - Professional notes section
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||
// NOTES SECTION
|
||||
worksheet.mergeCells(`A${currentRow}:F${currentRow}`);
|
||||
const notesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||
notesHeaderCell.value = 'Notes & Comments';
|
||||
notesHeaderCell.style = {
|
||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -495,13 +737,12 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
|
||||
currentRow++;
|
||||
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow + 3}`);
|
||||
worksheet.mergeCells(`A${currentRow}:F${currentRow + 3}`);
|
||||
const notesContentCell = worksheet.getCell(`A${currentRow}`);
|
||||
notesContentCell.value = report.notes || 'No additional notes';
|
||||
notesContentCell.style = {
|
||||
font: { name: 'Arial', size: 11 },
|
||||
alignment: { horizontal: 'left', vertical: 'top', wrapText: true },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||
alignment: { horizontal: 'center', vertical: 'middle', wrapText: true },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -510,16 +751,16 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
currentRow += 6; // Skip to footer
|
||||
|
||||
// 11. FOOTER SECTION - Professional footer
|
||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||
return currentRow + 5;
|
||||
}
|
||||
|
||||
async function addFooter(worksheet: ExcelJS.Worksheet, currentRow: number): Promise<number> {
|
||||
worksheet.mergeCells(`A${currentRow}:F${currentRow}`);
|
||||
const footerCell = worksheet.getCell(`A${currentRow}`);
|
||||
footerCell.value = 'موقعة لأعمال الصيانة';
|
||||
footerCell.value = '';
|
||||
footerCell.style = {
|
||||
font: { name: 'Arial', size: 12, bold: true },
|
||||
font: { name: 'Arial', size: 12 },
|
||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } },
|
||||
border: {
|
||||
top: { style: 'thick', color: { argb: '000000' } },
|
||||
left: { style: 'thick', color: { argb: '000000' } },
|
||||
@ -528,24 +769,15 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
// Set row heights for professional appearance
|
||||
// Set row heights for better appearance
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber <= 3) {
|
||||
row.height = 25; // Header rows
|
||||
} else if (row.getCell(1).value && typeof row.getCell(1).value === 'string' &&
|
||||
(row.getCell(1).value.includes('Shift') ||
|
||||
row.getCell(1).value.includes('Stoppages') ||
|
||||
row.getCell(1).value.includes('Notes'))) {
|
||||
row.height = 22; // Section headers
|
||||
} else {
|
||||
row.height = 18; // Standard rows
|
||||
}
|
||||
row.height = 20;
|
||||
});
|
||||
|
||||
// Set print settings for professional output
|
||||
// Set print settings
|
||||
worksheet.pageSetup = {
|
||||
paperSize: 9, // A4
|
||||
orientation: 'landscape',
|
||||
orientation: 'portrait',
|
||||
fitToPage: true,
|
||||
fitToWidth: 1,
|
||||
fitToHeight: 0,
|
||||
@ -559,10 +791,5 @@ export async function exportReportToExcel(report: ReportData) {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate and save file
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
const fileName = `Report_${report.id}_${new Date(report.createdDate).toLocaleDateString('en-GB').replace(/\//g, '-')}.xlsx`;
|
||||
FileSaver.saveAs(blob, fileName);
|
||||
return currentRow + 1;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import * as nodemailer from "nodemailer";
|
||||
import { prisma } from "~/utils/db.server";
|
||||
import { decryptMailSettings } from "~/utils/encryption.server";
|
||||
|
||||
interface EmailOptions {
|
||||
to: string | string[];
|
||||
@ -16,12 +17,15 @@ interface EmailOptions {
|
||||
export async function sendEmail(options: EmailOptions) {
|
||||
try {
|
||||
// Get mail settings from database
|
||||
const mailSettings = await prisma.mailSettings.findFirst();
|
||||
const encryptedMailSettings = await prisma.mailSettings.findFirst();
|
||||
|
||||
if (!mailSettings) {
|
||||
if (!encryptedMailSettings) {
|
||||
throw new Error("Mail settings not configured. Please configure SMTP settings first.");
|
||||
}
|
||||
|
||||
// Decrypt the mail settings
|
||||
const mailSettings = decryptMailSettings(encryptedMailSettings);
|
||||
|
||||
// Create transporter with enhanced configuration
|
||||
const transportConfig: any = {
|
||||
host: mailSettings.host,
|
||||
@ -29,7 +33,7 @@ export async function sendEmail(options: EmailOptions) {
|
||||
secure: mailSettings.secure,
|
||||
auth: {
|
||||
user: mailSettings.username,
|
||||
pass: mailSettings.password,
|
||||
pass: mailSettings.password, // Now decrypted
|
||||
},
|
||||
};
|
||||
|
||||
@ -77,22 +81,25 @@ export async function sendEmail(options: EmailOptions) {
|
||||
|
||||
export async function testEmailConnection() {
|
||||
try {
|
||||
const mailSettings = await prisma.mailSettings.findFirst();
|
||||
const encryptedMailSettings = await prisma.mailSettings.findFirst();
|
||||
|
||||
if (!mailSettings) {
|
||||
if (!encryptedMailSettings) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Mail settings not configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt the mail settings
|
||||
const mailSettings = decryptMailSettings(encryptedMailSettings);
|
||||
|
||||
const transportConfig: any = {
|
||||
host: mailSettings.host,
|
||||
port: mailSettings.port,
|
||||
secure: mailSettings.secure, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: mailSettings.username,
|
||||
pass: mailSettings.password,
|
||||
pass: mailSettings.password, // Now decrypted
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
BIN
public/logo03.png
Normal file
BIN
public/logo03.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Loading…
Reference in New Issue
Block a user