wwvvv
This commit is contained in:
parent
b43819aa7b
commit
17f1acfbb0
@ -12,6 +12,7 @@ DATABASE_URL="file:/app/data/production.db"
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
SESSION_SECRET="your-super-secure-session-secret-change-this-in-production-min-32-chars"
|
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 Account (created on first run)
|
||||||
SUPER_ADMIN="superadmin"
|
SUPER_ADMIN="superadmin"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ node_modules
|
|||||||
/.cache
|
/.cache
|
||||||
/build
|
/build
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
/generated/prisma
|
/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 type { Employee } from "@prisma/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -7,50 +8,87 @@ interface DashboardLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children, user }: 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<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 */}
|
{/* Navigation */}
|
||||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
<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-0 sm:px-6 lg:px-8">
|
<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 justify-between h-16">
|
||||||
<div className="flex items-center">
|
<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
|
<img
|
||||||
className="h-12 w-auto justify-self-start"
|
className="h-14 w-auto"
|
||||||
src="/clogo-sm.png"
|
src="/logo03.png"
|
||||||
alt="Phosphat Report"
|
alt="Phosphat Report"
|
||||||
/>
|
/>
|
||||||
{/* <div className="ml-4">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900">
|
|
||||||
Phosphat Report Dashboard
|
|
||||||
</h1>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<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">
|
<div className="text-sm text-gray-700">
|
||||||
Welcome, <span className="font-medium">{user.name}</span>
|
Welcome, <span className="font-medium">{user.name}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Form method="post" action="/logout">
|
<Form method="post" action="/logout">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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" />
|
<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>
|
</svg>
|
||||||
Logout
|
<span className="hidden sm:inline">Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
@ -58,176 +96,253 @@ export default function DashboardLayout({ children, user }: DashboardLayoutProps
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Sidebar */}
|
<div className="flex pt-16">
|
||||||
<div className="flex">
|
{/* Desktop Sidebar */}
|
||||||
<div className="w-64 bg-white shadow-sm min-h-screen">
|
<div className={`hidden lg:flex lg:flex-shrink-0 transition-all duration-300 ${sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
|
||||||
<nav className="mt-8 px-4">
|
}`}>
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-col w-full">
|
||||||
<li>
|
<div className="flex flex-col flex-grow bg-white shadow-sm border-r border-gray-200 pt-5 pb-4 overflow-y-auto">
|
||||||
<Link
|
<SidebarContent
|
||||||
to="/dashboard"
|
user={user}
|
||||||
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
|
collapsed={sidebarCollapsed}
|
||||||
>
|
onItemClick={() => { }}
|
||||||
<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" />
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
{/* Mobile Sidebar */}
|
||||||
<Link
|
<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'
|
||||||
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"
|
<div className="flex flex-col h-full pt-16">
|
||||||
>
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<SidebarContent
|
||||||
<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" />
|
user={user}
|
||||||
</svg>
|
collapsed={false}
|
||||||
Mail Settings
|
onItemClick={() => setSidebarOpen(false)}
|
||||||
</Link>
|
/>
|
||||||
</li>
|
</div>
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 p-8">
|
<div className="flex-1 flex flex-col">
|
||||||
|
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||||
{children}
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
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>
|
<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})`}>
|
<option key={item.id} value={`${item.model} (${item.number})`}>
|
||||||
{item.category} - {item.model} ({item.number})
|
{item.category} - {item.model} ({item.number})
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -21,14 +21,77 @@ export default function ReportSheetViewModal({ isOpen, onClose, sheet }: ReportS
|
|||||||
if (!isOpen || !sheet) return null;
|
if (!isOpen || !sheet) return null;
|
||||||
|
|
||||||
const handleExportExcel = async () => {
|
const handleExportExcel = async () => {
|
||||||
if (sheet.dayReport) {
|
// Export the entire sheet (both day and night reports combined)
|
||||||
await exportReportToExcel(sheet.dayReport);
|
await exportReportToExcel(sheet);
|
||||||
}
|
|
||||||
if (sheet.nightReport) {
|
|
||||||
await exportReportToExcel(sheet.nightReport);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<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 />
|
<ReportSheetFooter />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -191,12 +368,22 @@ function ReportSheetHeader({ sheet }: { sheet: ReportSheet }) {
|
|||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border-r-2 border-black p-2 text-center font-bold text-lg" style={{ width: '70%' }}>
|
<td className=" p-2 text-center" style={{ width: '25%' }}>
|
||||||
<div>Reclamation Work Diary - Daily Sheet</div>
|
<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">QF-3.6.1-08</div>
|
||||||
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-center" style={{ width: '30%' }}>
|
<td className="p-2 text-center" style={{ width: '25%' }}>
|
||||||
<img
|
<img
|
||||||
src="/logo-light.png"
|
src="/logo-light.png"
|
||||||
alt="Arab Potash Logo"
|
alt="Arab Potash Logo"
|
||||||
|
|||||||
@ -154,12 +154,23 @@ function ReportHeader() {
|
|||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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>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">QF-3.6.1-08</div>
|
||||||
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-center" style={{ width: '30%' }}>
|
<td className="p-2 text-center" style={{ width: '25%' }}>
|
||||||
<img
|
<img
|
||||||
src="/logo-light.png"
|
src="/logo-light.png"
|
||||||
alt="Arab Potash Logo"
|
alt="Arab Potash Logo"
|
||||||
|
|||||||
@ -125,14 +125,14 @@ export default function Areas() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="mt-1 text-sm text-gray-600">Manage operational areas for your reports</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
@ -141,8 +141,9 @@ export default function Areas() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Areas Table */}
|
{/* Areas Table - Desktop */}
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
|
<div className="hidden sm:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
@ -212,6 +213,58 @@ export default function Areas() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{areas.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<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">
|
<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 */}
|
{/* Welcome Section */}
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<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}!
|
Welcome back, {user.name}!
|
||||||
</h2>
|
</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.
|
Here's what's happening with your phosphat operations today.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* 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="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -129,10 +129,10 @@ export default function Dashboard() {
|
|||||||
{/* Recent Reports */}
|
{/* Recent Reports */}
|
||||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
<div className="px-4 py-5 sm:px-6">
|
<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
|
Recent Reports
|
||||||
</h3>
|
</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
|
Latest activity from your team
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -141,7 +141,7 @@ export default function Dashboard() {
|
|||||||
recentReports.map((report) => (
|
recentReports.map((report) => (
|
||||||
<li key={report.id}>
|
<li key={report.id}>
|
||||||
<div className="px-4 py-4 sm:px-6">
|
<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 items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="h-8 w-8 rounded-full bg-indigo-500 flex items-center justify-center">
|
<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">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{report.employee.name}
|
{report.employee.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-xs sm:text-sm text-gray-500">
|
||||||
{report.area.name} - {report.shift} shift
|
{report.area.name} - {report.shift} shift
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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')}
|
{new Date(report.createdDate).toLocaleDateString('en-GB')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -169,7 +169,7 @@ export default function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<li>
|
<li>
|
||||||
<div className="px-4 py-4 sm:px-6 text-center text-gray-500">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -342,14 +342,14 @@ export default function Employees() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="mt-1 text-sm text-gray-600">Manage system users and their access levels</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
@ -358,8 +358,9 @@ export default function Employees() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Employees Table */}
|
{/* Employees Table - Desktop */}
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
|
<div className="hidden lg:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
@ -473,6 +474,90 @@ export default function Employees() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{employees.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<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">
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -507,6 +592,7 @@ export default function Employees() {
|
|||||||
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
|
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
|
||||||
{isEditing && <input type="hidden" name="id" value={editingEmployee?.id} />}
|
{isEditing && <input type="hidden" name="id" value={editingEmployee?.id} />}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@ -545,7 +631,6 @@ export default function Employees() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Email Address
|
Email Address
|
||||||
@ -563,7 +648,6 @@ export default function Employees() {
|
|||||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@ -605,6 +689,7 @@ export default function Employees() {
|
|||||||
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEditing && editingEmployee?.id !== user.id && (
|
{isEditing && editingEmployee?.id !== user.id && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -166,14 +166,14 @@ export default function Equipment() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="mt-1 text-sm text-gray-600">Manage your fleet equipment and machinery</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
@ -182,8 +182,9 @@ export default function Equipment() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Equipment Table */}
|
{/* Equipment Table - Desktop */}
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
|
<div className="hidden sm:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
@ -256,6 +257,56 @@ export default function Equipment() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{equipment.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<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">
|
<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 DashboardLayout from "~/components/DashboardLayout";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { prisma } from "~/utils/db.server";
|
import { prisma } from "~/utils/db.server";
|
||||||
|
import { encryptMailSettings, decryptMailSettings, safeEncryptPassword } from "~/utils/encryption.server";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
// Require auth level 3 to access mail settings
|
// Require auth level 3 to access mail settings
|
||||||
const user = await requireAuthLevel(request, 3);
|
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 });
|
return json({ mailSettings, user });
|
||||||
}
|
}
|
||||||
@ -40,6 +51,9 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Encrypt the password before saving
|
||||||
|
const encryptedPassword = safeEncryptPassword(password);
|
||||||
|
|
||||||
// Check if settings exist
|
// Check if settings exist
|
||||||
const existingSettings = await prisma.mailSettings.findFirst();
|
const existingSettings = await prisma.mailSettings.findFirst();
|
||||||
|
|
||||||
@ -52,7 +66,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
port,
|
port,
|
||||||
secure,
|
secure,
|
||||||
username,
|
username,
|
||||||
password,
|
password: encryptedPassword, // Store encrypted password
|
||||||
fromName,
|
fromName,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
},
|
},
|
||||||
@ -65,7 +79,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
port,
|
port,
|
||||||
secure,
|
secure,
|
||||||
username,
|
username,
|
||||||
password,
|
password: encryptedPassword, // Store encrypted password
|
||||||
fromName,
|
fromName,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
},
|
},
|
||||||
@ -74,6 +88,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
|
|
||||||
return json({ success: "Mail settings saved successfully" });
|
return json({ success: "Mail settings saved successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Mail settings save error:", error);
|
||||||
return json({ error: "Failed to save mail settings" }, { status: 500 });
|
return json({ error: "Failed to save mail settings" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,13 +100,13 @@ export default function MailSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="max-w-2xl mx-auto p-6">
|
<div className="max-w-2xl mx-auto p-4 sm:p-6">
|
||||||
<h1 className="text-2xl font-bold mb-6">Mail Settings</h1>
|
<h1 className="text-xl sm:text-2xl font-bold mb-6">Mail Settings</h1>
|
||||||
|
|
||||||
{/* SMTP Configuration Examples */}
|
{/* SMTP Configuration Examples */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<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>
|
<h3 className="text-base sm: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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-blue-700">Gmail</h4>
|
<h4 className="font-medium text-blue-700">Gmail</h4>
|
||||||
<p>Host: smtp.gmail.com</p>
|
<p>Host: smtp.gmail.com</p>
|
||||||
@ -240,7 +255,7 @@ export default function MailSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
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"
|
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,13 +110,14 @@ export default function ReportSheet() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-sm text-gray-600">View grouped reports by location and date</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Report Sheets Table */}
|
{/* Report Sheets Table - Desktop */}
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
|
<div className="hidden lg:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
@ -217,6 +218,90 @@ export default function ReportSheet() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{sheets.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<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">
|
<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
|
// First period
|
||||||
if (from1 && to1) {
|
if (from1 && to1) {
|
||||||
const start1 = parseTime(from1);
|
const start1 = parseTime(from1);
|
||||||
const end1 = parseTime(to1);
|
let end1 = parseTime(to1);
|
||||||
|
if(end1 < start1)
|
||||||
|
end1 += 24 * 60;
|
||||||
totalMinutes += end1 - start1;
|
totalMinutes += end1 - start1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second period
|
// Second period
|
||||||
if (from2 && to2) {
|
if (from2 && to2) {
|
||||||
const start2 = parseTime(from2);
|
const start2 = parseTime(from2);
|
||||||
const end2 = parseTime(to2);
|
let end2 = parseTime(to2);
|
||||||
|
if(end2 < start2)
|
||||||
|
end2 += 24 * 60;
|
||||||
totalMinutes += end2 - start2;
|
totalMinutes += end2 - start2;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatTime(Math.max(0, totalMinutes));
|
return formatTime(Math.max(0, totalMinutes));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const calculateStoppageTime = (from: string, to: string) => {
|
const calculateStoppageTime = (from: string, to: string) => {
|
||||||
if (!from || !to) return "00:00";
|
if (!from || !to) return "00:00";
|
||||||
|
|
||||||
@ -436,7 +442,10 @@ export default function Reports() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startMinutes = parseTime(from);
|
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);
|
const totalMinutes = Math.max(0, endMinutes - startMinutes);
|
||||||
|
|
||||||
return formatTime(totalMinutes);
|
return formatTime(totalMinutes);
|
||||||
@ -540,14 +549,14 @@ export default function Reports() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="mt-1 text-sm text-gray-600">Create and manage operational shifts</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/reports/new"
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
@ -556,8 +565,9 @@ export default function Reports() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reports Table */}
|
{/* Reports Table - Desktop */}
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
|
<div className="hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
@ -612,7 +622,6 @@ export default function Reports() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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')}
|
{new Date(report.createdDate).toLocaleDateString('en-GB')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
@ -655,8 +664,90 @@ export default function Reports() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
<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>
|
||||||
|
</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={() => 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"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{reports.length === 0 && (
|
{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">
|
<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" />
|
<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>
|
</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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
Create Shifs
|
Create Shifts
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -250,17 +250,28 @@ export default function NewReport() {
|
|||||||
|
|
||||||
if (from1 && to1) {
|
if (from1 && to1) {
|
||||||
const start1 = parseTime(from1);
|
const start1 = parseTime(from1);
|
||||||
const end1 = parseTime(to1);
|
let end1 = parseTime(to1);
|
||||||
|
|
||||||
|
if (end1 < start1) {
|
||||||
|
end1 += 24 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
totalMinutes += end1 - start1;
|
totalMinutes += end1 - start1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from2 && to2) {
|
if (from2 && to2) {
|
||||||
const start2 = parseTime(from2);
|
const start2 = parseTime(from2);
|
||||||
const end2 = parseTime(to2);
|
let end2 = parseTime(to2);
|
||||||
|
|
||||||
|
|
||||||
|
if (end2 < start2) {
|
||||||
|
end2 += 24 * 60;
|
||||||
|
}
|
||||||
totalMinutes += end2 - start2;
|
totalMinutes += end2 - start2;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatTime(Math.max(0, totalMinutes));
|
return formatTime(Math.max(0, totalMinutes));
|
||||||
|
//return formatTime(Math.max(totalMinutes * -1, totalMinutes));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateStoppageTime = (from: string, to: string) => {
|
const calculateStoppageTime = (from: string, to: string) => {
|
||||||
@ -278,7 +289,9 @@ export default function NewReport() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startMinutes = parseTime(from);
|
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);
|
const totalMinutes = Math.max(0, endMinutes - startMinutes);
|
||||||
|
|
||||||
return formatTime(totalMinutes);
|
return formatTime(totalMinutes);
|
||||||
@ -384,15 +397,15 @@ export default function NewReport() {
|
|||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="max-w-full mx-auto">
|
<div className="max-w-full mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Create New Shifts</h1>
|
<h1 className="text-2xl sm: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>
|
<p className="mt-2 text-sm sm:text-base text-gray-600">Fill out the operational shift details step by step</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/reports"
|
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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between px-2 sm:px-0">
|
||||||
{[1, 2, 3, 4].map((step) => (
|
{[1, 2, 3, 4].map((step) => (
|
||||||
<div key={step} className="flex items-center">
|
<div key={step} className="flex items-center">
|
||||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-2 ${
|
<div className={`flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-full border-2 ${step <= currentStep
|
||||||
step <= currentStep
|
|
||||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||||
: 'border-gray-300 text-gray-500'
|
: 'border-gray-300 text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
{step < currentStep ? (
|
{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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium">{step}</span>
|
<span className="text-xs sm:text-sm font-medium">{step}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{step < totalSteps && (
|
{step < totalSteps && (
|
||||||
<div className={`flex-1 h-1 mx-4 ${
|
<div className={`flex-1 h-1 mx-2 sm:mx-4 ${step < currentStep ? 'bg-indigo-600' : 'bg-gray-300'
|
||||||
step < currentStep ? 'bg-indigo-600' : 'bg-gray-300'
|
|
||||||
}`} />
|
}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{getStepTitle(currentStep)}</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{getStepTitle(currentStep)}</h2>
|
||||||
<p className="text-sm text-gray-500">Step {currentStep} of {totalSteps}</p>
|
<p className="text-xs sm:text-sm text-gray-500">Step {currentStep} of {totalSteps}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<Form method="post" onSubmit={handleSubmit} className="bg-white shadow-lg rounded-lg overflow-hidden">
|
<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 */}
|
{/* Step 1: Basic Information */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Shift <span className="text-red-500">*</span>
|
Shift <span className="text-red-500">*</span>
|
||||||
@ -486,7 +497,7 @@ export default function NewReport() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="dredgerLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="dredgerLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Dredger Location <span className="text-red-500">*</span>
|
Dredger Location <span className="text-red-500">*</span>
|
||||||
@ -535,8 +546,8 @@ export default function NewReport() {
|
|||||||
)}
|
)}
|
||||||
{/* Step 2: Location & Pipeline Details */}
|
{/* Step 2: Location & Pipeline Details */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Reclamation Location <span className="text-red-500">*</span>
|
Reclamation Location <span className="text-red-500">*</span>
|
||||||
@ -560,15 +571,15 @@ export default function NewReport() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Reclamation Height</h3>
|
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Reclamation Height</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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="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><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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Pipeline Length</h3>
|
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Pipeline Length</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<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="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="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>
|
<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>
|
||||||
@ -579,10 +590,10 @@ export default function NewReport() {
|
|||||||
)}
|
)}
|
||||||
{/* Step 3: Equipment & Time Sheet */}
|
{/* Step 3: Equipment & Time Sheet */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
|
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||||
<div><label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers</label><input type="number" id="statsDozers" name="statsDozers" min="0" value={formData.statsDozers} onChange={(e) => updateFormData('statsDozers', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
|
<div><label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers</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="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>
|
<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) => (
|
{timeSheetEntries.map((entry) => (
|
||||||
<div key={entry.id} className="bg-gray-50 p-4 rounded-lg">
|
<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 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">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">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>
|
<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>
|
||||||
@ -672,18 +687,18 @@ export default function NewReport() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-between">
|
<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 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'}`}>
|
<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
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
{currentStep < totalSteps ? (
|
{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>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<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 ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -45,15 +45,15 @@ export default function SignIn() {
|
|||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
|
|
||||||
return (
|
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="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-8">
|
<div className="max-w-md w-full space-y-6 sm:space-y-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<img
|
||||||
className="mx-auto h-28 w-auto"
|
className="mx-auto h-20 sm:h-28 w-auto"
|
||||||
src="/clogo-sm.png"
|
src="/clogo-sm.png"
|
||||||
alt="Phosphat Report"
|
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
|
Sign in to your account
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
|||||||
@ -81,15 +81,15 @@ export default function SignUp() {
|
|||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
|
|
||||||
return (
|
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="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-8">
|
<div className="max-w-md w-full space-y-6 sm:space-y-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<img
|
||||||
className="mx-auto h-24 w-auto"
|
className="mx-auto h-20 sm:h-24 w-auto"
|
||||||
src="/clogo-sm.png"
|
src="/clogo-sm.png"
|
||||||
alt="Phosphat Report"
|
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
|
Create your account
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
|||||||
@ -43,8 +43,8 @@ export default function TestEmail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout user={user}>
|
<DashboardLayout user={user}>
|
||||||
<div className="max-w-2xl mx-auto p-6">
|
<div className="max-w-2xl mx-auto p-4 sm:p-6">
|
||||||
<h1 className="text-2xl font-bold mb-6">Test Email</h1>
|
<h1 className="text-xl sm:text-2xl font-bold mb-6">Test Email</h1>
|
||||||
<p className="text-gray-600 mb-6">
|
<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.
|
Use this form to test your email configuration. Only users with auth level 3 can access this feature.
|
||||||
</p>
|
</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;
|
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 workbook = new ExcelJS.Workbook();
|
||||||
const worksheet = workbook.addWorksheet('Report');
|
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 = [
|
worksheet.columns = [
|
||||||
{ width: 30 }, // A - Labels/Machine names
|
{ width: 25 }, // A - First column (25% width)
|
||||||
{ width: 20 }, // B - Data/Values
|
{ width: 25 }, // B - Second column (25% width)
|
||||||
{ width: 20 }, // C - Labels/Data
|
{ width: 25 }, // C - Third column (25% width)
|
||||||
{ width: 20 }, // D - Data/Values
|
{ width: 25 }, // D - Fourth column (25% width)
|
||||||
{ width: 15 }, // E - Pipeline data
|
|
||||||
{ width: 15 }, // F - Pipeline data
|
|
||||||
{ width: 25 } // G - Reason/Notes
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentRow = 1;
|
let currentRow = 1;
|
||||||
|
|
||||||
// 1. HEADER SECTION - Professional layout matching reference file
|
// Check if this is a ReportSheet (combined day/night) or single Report
|
||||||
// Main header with company info
|
const isReportSheet = 'dayReport' in reportOrSheet || 'nightReport' in reportOrSheet;
|
||||||
worksheet.mergeCells(`A${currentRow}:E${currentRow + 2}`);
|
|
||||||
const headerCell = worksheet.getCell(`A${currentRow}`);
|
if (isReportSheet) {
|
||||||
headerCell.value = 'Reclamation Work Diary';
|
await exportReportSheet(worksheet, reportOrSheet as ReportSheet, currentRow);
|
||||||
headerCell.style = {
|
} 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 },
|
font: { name: 'Arial', size: 16, bold: true },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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' } }
|
right: { style: 'thick', color: { argb: '000000' } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logo area
|
// QF code
|
||||||
worksheet.mergeCells(`F${currentRow}:G${currentRow + 2}`);
|
worksheet.mergeCells(`B${currentRow + 1}:C${currentRow + 1}`);
|
||||||
const logoCell = worksheet.getCell(`F${currentRow}`);
|
const qfCell = worksheet.getCell(`B${currentRow + 1}`);
|
||||||
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}`);
|
|
||||||
qfCell.value = 'QF-3.6.1-08';
|
qfCell.value = 'QF-3.6.1-08';
|
||||||
qfCell.style = {
|
qfCell.style = {
|
||||||
font: { name: 'Arial', size: 10 },
|
font: { name: 'Arial', size: 12 },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thin', color: { argb: '000000' } },
|
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.value = 'Rev. 1.0';
|
||||||
revCell.style = {
|
revCell.style = {
|
||||||
font: { name: 'Arial', size: 10 },
|
font: { name: 'Arial', size: 12 },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thin', color: { argb: '000000' } },
|
top: { style: 'thin', color: { argb: '000000' } },
|
||||||
@ -110,18 +254,13 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
currentRow += 4; // Skip to next section
|
// Right logo
|
||||||
|
worksheet.mergeCells(`D${currentRow}:D${currentRow + 2}`);
|
||||||
// 2. REPORT INFO SECTION - Professional table layout
|
const logoRightCell = worksheet.getCell(`D${currentRow}`);
|
||||||
const infoRowCells = [
|
logoRightCell.value = '';
|
||||||
{ col: 'A', label: 'Date:', value: new Date(report.createdDate).toLocaleDateString('en-GB') },
|
logoRightCell.style = {
|
||||||
{ col: 'C', label: 'Report No.', value: report.id.toString() }
|
font: { name: 'Arial', size: 12, bold: true },
|
||||||
];
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
|
|
||||||
// Create bordered info section
|
|
||||||
['A', 'B', 'C', 'D', 'E', 'F', 'G'].forEach(col => {
|
|
||||||
const cell = worksheet.getCell(`${col}${currentRow}`);
|
|
||||||
cell.style = {
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -129,14 +268,76 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
right: { 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}`);
|
const dateCell = worksheet.getCell(`A${currentRow}`);
|
||||||
dateCell.value = 'Date:';
|
dateCell.value = 'Date:';
|
||||||
dateCell.style = {
|
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' },
|
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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}`);
|
const dateValueCell = worksheet.getCell(`B${currentRow}`);
|
||||||
dateValueCell.value = new Date(report.createdDate).toLocaleDateString('en-GB');
|
dateValueCell.value = new Date(report.createdDate).toLocaleDateString('en-GB');
|
||||||
dateValueCell.style = {
|
dateValueCell.style = {
|
||||||
font: { name: 'Arial', size: 11 },
|
font: { name: 'Arial', size: 12 },
|
||||||
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 },
|
|
||||||
alignment: { horizontal: 'left', vertical: 'middle' },
|
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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}`);
|
const shiftNoCell = worksheet.getCell(`C${currentRow}`);
|
||||||
reportNoValueCell.value = report.id.toString();
|
shiftNoCell.value = 'Shift No.';
|
||||||
reportNoValueCell.style = {
|
shiftNoCell.style = {
|
||||||
font: { name: 'Arial', size: 11 },
|
font: { name: 'Arial', size: 12, bold: true },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'left', vertical: 'middle' },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -185,53 +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
|
return currentRow + 2;
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
}
|
||||||
|
|
||||||
|
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}`);
|
const dredgerCell = worksheet.getCell(`A${currentRow}`);
|
||||||
dredgerCell.value = `${report.area.name} Dredger`;
|
dredgerCell.value = `${areaName} Dredger`;
|
||||||
dredgerCell.style = {
|
dredgerCell.style = {
|
||||||
font: { name: 'Arial', size: 18, bold: true, underline: true },
|
font: { name: 'Arial', size: 18, bold: true, underline: true },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' }
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
currentRow += 2; // Skip empty row
|
return currentRow + 2;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. LOCATION DATA SECTION - Professional table with green headers
|
async function addLocationData(worksheet: ExcelJS.Worksheet, report: ReportData, currentRow: number): Promise<number> {
|
||||||
const locationRows = [
|
const locationData = [
|
||||||
['Dredger Location', report.dredgerLocation.name, '', 'Dredger Line Length', report.dredgerLineLength.toString()],
|
['Dredger Location', report.dredgerLocation.name, 'Dredger Line Length', report.dredgerLineLength.toString()],
|
||||||
['Reclamation Location', report.reclamationLocation.name, '', 'Shore Connection', report.shoreConnection.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`, '', '', '']
|
['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;
|
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);
|
|
||||||
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' } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
rowData.forEach((cellValue, colIndex) => {
|
rowData.forEach((cellValue, colIndex) => {
|
||||||
if (cellValue !== '') {
|
|
||||||
const cell = worksheet.getCell(row, colIndex + 1);
|
const cell = worksheet.getCell(row, colIndex + 1);
|
||||||
cell.value = cellValue;
|
cell.value = cellValue;
|
||||||
|
|
||||||
const isGreenHeader = (colIndex === 0 || colIndex === 3);
|
const isGreenHeader = (colIndex === 0 || colIndex === 2) && cellValue !== '';
|
||||||
cell.style = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 11, bold: isGreenHeader, color: isGreenHeader ? { argb: 'FFFFFF' } : { argb: '000000' } },
|
font: { name: 'Arial', size: 11, bold: isGreenHeader, color: isGreenHeader ? { argb: 'FFFFFF' } : { argb: '000000' } },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: isGreenHeader ? { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } } : { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
fill: isGreenHeader ? { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } } : { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -239,33 +428,31 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
right: { style: 'thick', color: { argb: '000000' } }
|
right: { style: 'thick', color: { argb: '000000' } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge cells for better layout
|
return currentRow + 4;
|
||||||
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
|
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
|
// Pipeline header row
|
||||||
const pipelineHeaderRow = currentRow;
|
const pipelineHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||||
|
pipelineHeaderCell.value = 'Pipeline Length "from Shore Connection"';
|
||||||
// First row - main header with rowspan
|
pipelineHeaderCell.style = {
|
||||||
const mainHeaderCell = worksheet.getCell(pipelineHeaderRow, 1);
|
|
||||||
mainHeaderCell.value = 'Pipeline Length "from Shore Connection"';
|
|
||||||
mainHeaderCell.style = {
|
|
||||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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'];
|
const pipelineSubHeaders = ['Main', 'extension', 'total', 'Reserve', 'extension', 'total'];
|
||||||
pipelineSubHeaders.forEach((header, colIndex) => {
|
pipelineSubHeaders.forEach((header, colIndex) => {
|
||||||
const cell = worksheet.getCell(pipelineHeaderRow, colIndex + 2);
|
const cell = worksheet.getCell(currentRow, colIndex + 2);
|
||||||
cell.value = header;
|
cell.value = header;
|
||||||
cell.style = {
|
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' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -292,9 +479,20 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Data row
|
// Pipeline data row
|
||||||
const pipelineDataRow = currentRow + 1;
|
currentRow++;
|
||||||
const pipelineData = ['',
|
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?.main || 0).toString(),
|
||||||
(report.pipelineLength?.ext1 || 0).toString(),
|
(report.pipelineLength?.ext1 || 0).toString(),
|
||||||
((report.pipelineLength?.main || 0) + (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) => {
|
pipelineData.forEach((data, colIndex) => {
|
||||||
const cell = worksheet.getCell(pipelineDataRow, colIndex + 1);
|
const cell = worksheet.getCell(currentRow, colIndex + 2);
|
||||||
cell.value = data;
|
cell.value = data;
|
||||||
cell.style = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 11 },
|
font: { name: 'Arial', size: 11 },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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
|
return currentRow + 2;
|
||||||
|
}
|
||||||
|
|
||||||
// 6. SHIFT HEADER SECTION - Professional full-width header
|
async function addShiftSection(worksheet: ExcelJS.Worksheet, report: ReportData, shiftName: string, currentRow: number): Promise<number> {
|
||||||
|
// SHIFT HEADER SECTION
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||||
const shiftCell = worksheet.getCell(`A${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 = {
|
shiftCell.style = {
|
||||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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'];
|
const equipmentHeaders = ['Dozers', 'Exc.', 'Loader', 'Foreman', 'Laborer'];
|
||||||
|
|
||||||
// Apply borders to all cells in the equipment section
|
// Adjust columns back to 5 for equipment section
|
||||||
for (let col = 1; col <= 7; col++) {
|
worksheet.columns = [
|
||||||
for (let row = currentRow; row <= currentRow + 1; row++) {
|
{ width: 20 }, // A - Dozers
|
||||||
const cell = worksheet.getCell(row, col);
|
{ width: 20 }, // B - Exc.
|
||||||
cell.style = {
|
{ width: 20 }, // C - Loader
|
||||||
border: {
|
{ width: 20 }, // D - Foreman
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
{ width: 20 } // E - Laborer
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
];
|
||||||
bottom: { style: 'thick', color: { argb: '000000' } },
|
|
||||||
right: { style: 'thick', color: { argb: '000000' } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
equipmentHeaders.forEach((header, colIndex) => {
|
equipmentHeaders.forEach((header, colIndex) => {
|
||||||
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||||
cell.value = header;
|
cell.value = header;
|
||||||
cell.style = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 11, bold: true },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -373,6 +565,7 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
currentRow++;
|
||||||
const equipmentData = [
|
const equipmentData = [
|
||||||
(report.stats?.Dozers || 0).toString(),
|
(report.stats?.Dozers || 0).toString(),
|
||||||
(report.stats?.Exc || 0).toString(),
|
(report.stats?.Exc || 0).toString(),
|
||||||
@ -382,12 +575,11 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
equipmentData.forEach((data, colIndex) => {
|
equipmentData.forEach((data, colIndex) => {
|
||||||
const cell = worksheet.getCell(currentRow + 1, colIndex + 1);
|
const cell = worksheet.getCell(currentRow, colIndex + 1);
|
||||||
cell.value = data;
|
cell.value = data;
|
||||||
cell.style = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 11 },
|
font: { name: 'Arial', size: 11 },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -397,18 +589,28 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
currentRow += 4; // Skip empty row
|
currentRow += 2;
|
||||||
|
|
||||||
// 8. TIME SHEET SECTION - Professional table
|
// TIME SHEET SECTION
|
||||||
const createProfessionalTable = (headers: string[], data: any[][], startRow: number) => {
|
// Expand to 7 columns for time sheet
|
||||||
// Headers
|
worksheet.columns = [
|
||||||
headers.forEach((header, colIndex) => {
|
{ width: 20 }, // A - Time Sheet
|
||||||
const cell = worksheet.getCell(startRow, colIndex + 1);
|
{ 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.value = header;
|
||||||
cell.style = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 11, bold: true },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -418,16 +620,20 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Data rows
|
currentRow++;
|
||||||
data.forEach((rowData, rowIndex) => {
|
|
||||||
const row = startRow + rowIndex + 1;
|
const timeSheetData = Array.isArray(report.timeSheet) && report.timeSheet.length > 0
|
||||||
rowData.forEach((cellData, colIndex) => {
|
? report.timeSheet
|
||||||
const cell = worksheet.getCell(row, colIndex + 1);
|
: [{ machine: 'No time sheet entries', from1: '', to1: '', from2: '', to2: '', total: '', reason: '' }];
|
||||||
cell.value = cellData;
|
|
||||||
|
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 = {
|
cell.style = {
|
||||||
font: { name: 'Arial', size: 10, bold: colIndex === 0 },
|
font: { name: 'Arial', size: 10, bold: colIndex === 0 },
|
||||||
alignment: { horizontal: colIndex === 0 ? 'left' : 'center', vertical: 'middle' },
|
alignment: { horizontal: colIndex === 0 ? 'left' : 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -436,28 +642,19 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
currentRow++;
|
||||||
});
|
});
|
||||||
|
|
||||||
return startRow + data.length + 1;
|
currentRow += 1;
|
||||||
};
|
|
||||||
|
|
||||||
const timeSheetHeaders = ['Time Sheet', 'From', 'To', 'From', 'To', 'Total', 'Reason'];
|
// STOPPAGES SECTION
|
||||||
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 = createProfessionalTable(timeSheetHeaders, timeSheetData, currentRow);
|
|
||||||
|
|
||||||
currentRow += 2; // Skip empty row
|
|
||||||
|
|
||||||
// 9. STOPPAGES SECTION - Professional section with header
|
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
||||||
const stoppagesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
const stoppagesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||||
stoppagesHeaderCell.value = 'Dredger Stoppages';
|
stoppagesHeaderCell.value = 'Dredger Stoppages';
|
||||||
stoppagesHeaderCell.style = {
|
stoppagesHeaderCell.style = {
|
||||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -468,23 +665,68 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
|
|
||||||
currentRow++;
|
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
|
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, ''])
|
? report.stoppages
|
||||||
: [['No stoppages recorded', '', '', '', '', '', '']];
|
: [{ 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
|
// NOTES SECTION
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
worksheet.mergeCells(`A${currentRow}:F${currentRow}`);
|
||||||
const notesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
const notesHeaderCell = worksheet.getCell(`A${currentRow}`);
|
||||||
notesHeaderCell.value = 'Notes & Comments';
|
notesHeaderCell.value = 'Notes & Comments';
|
||||||
notesHeaderCell.style = {
|
notesHeaderCell.style = {
|
||||||
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
|
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' } },
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { style: 'thick', color: { argb: '000000' } },
|
left: { style: 'thick', color: { argb: '000000' } },
|
||||||
@ -495,13 +737,12 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
|
|
||||||
currentRow++;
|
currentRow++;
|
||||||
|
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow + 3}`);
|
worksheet.mergeCells(`A${currentRow}:F${currentRow + 3}`);
|
||||||
const notesContentCell = worksheet.getCell(`A${currentRow}`);
|
const notesContentCell = worksheet.getCell(`A${currentRow}`);
|
||||||
notesContentCell.value = report.notes || 'No additional notes';
|
notesContentCell.value = report.notes || 'No additional notes';
|
||||||
notesContentCell.style = {
|
notesContentCell.style = {
|
||||||
font: { name: 'Arial', size: 11 },
|
font: { name: 'Arial', size: 11 },
|
||||||
alignment: { horizontal: 'left', vertical: 'top', wrapText: true },
|
alignment: { horizontal: 'center', vertical: 'middle', wrapText: true },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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
|
return currentRow + 5;
|
||||||
|
}
|
||||||
|
|
||||||
// 11. FOOTER SECTION - Professional footer
|
async function addFooter(worksheet: ExcelJS.Worksheet, currentRow: number): Promise<number> {
|
||||||
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
|
worksheet.mergeCells(`A${currentRow}:F${currentRow}`);
|
||||||
const footerCell = worksheet.getCell(`A${currentRow}`);
|
const footerCell = worksheet.getCell(`A${currentRow}`);
|
||||||
footerCell.value = 'موقعة لأعمال الصيانة';
|
footerCell.value = '';
|
||||||
footerCell.style = {
|
footerCell.style = {
|
||||||
font: { name: 'Arial', size: 12, bold: true },
|
font: { name: 'Arial', size: 12 },
|
||||||
alignment: { horizontal: 'center', vertical: 'middle' },
|
alignment: { horizontal: 'center', vertical: 'middle' },
|
||||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } },
|
|
||||||
border: {
|
border: {
|
||||||
top: { style: 'thick', color: { argb: '000000' } },
|
top: { style: 'thick', color: { argb: '000000' } },
|
||||||
left: { 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) => {
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
if (rowNumber <= 3) {
|
row.height = 20;
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set print settings for professional output
|
// Set print settings
|
||||||
worksheet.pageSetup = {
|
worksheet.pageSetup = {
|
||||||
paperSize: 9, // A4
|
paperSize: 9, // A4
|
||||||
orientation: 'landscape',
|
orientation: 'portrait',
|
||||||
fitToPage: true,
|
fitToPage: true,
|
||||||
fitToWidth: 1,
|
fitToWidth: 1,
|
||||||
fitToHeight: 0,
|
fitToHeight: 0,
|
||||||
@ -559,10 +791,5 @@ export async function exportReportToExcel(report: ReportData) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate and save file
|
return currentRow + 1;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import * as nodemailer from "nodemailer";
|
import * as nodemailer from "nodemailer";
|
||||||
import { prisma } from "~/utils/db.server";
|
import { prisma } from "~/utils/db.server";
|
||||||
|
import { decryptMailSettings } from "~/utils/encryption.server";
|
||||||
|
|
||||||
interface EmailOptions {
|
interface EmailOptions {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
@ -16,12 +17,15 @@ interface EmailOptions {
|
|||||||
export async function sendEmail(options: EmailOptions) {
|
export async function sendEmail(options: EmailOptions) {
|
||||||
try {
|
try {
|
||||||
// Get mail settings from database
|
// 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.");
|
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
|
// Create transporter with enhanced configuration
|
||||||
const transportConfig: any = {
|
const transportConfig: any = {
|
||||||
host: mailSettings.host,
|
host: mailSettings.host,
|
||||||
@ -29,7 +33,7 @@ export async function sendEmail(options: EmailOptions) {
|
|||||||
secure: mailSettings.secure,
|
secure: mailSettings.secure,
|
||||||
auth: {
|
auth: {
|
||||||
user: mailSettings.username,
|
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() {
|
export async function testEmailConnection() {
|
||||||
try {
|
try {
|
||||||
const mailSettings = await prisma.mailSettings.findFirst();
|
const encryptedMailSettings = await prisma.mailSettings.findFirst();
|
||||||
|
|
||||||
if (!mailSettings) {
|
if (!encryptedMailSettings) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Mail settings not configured",
|
error: "Mail settings not configured",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt the mail settings
|
||||||
|
const mailSettings = decryptMailSettings(encryptedMailSettings);
|
||||||
|
|
||||||
const transportConfig: any = {
|
const transportConfig: any = {
|
||||||
host: mailSettings.host,
|
host: mailSettings.host,
|
||||||
port: mailSettings.port,
|
port: mailSettings.port,
|
||||||
secure: mailSettings.secure, // true for 465, false for other ports
|
secure: mailSettings.secure, // true for 465, false for other ports
|
||||||
auth: {
|
auth: {
|
||||||
user: mailSettings.username,
|
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