This commit is contained in:
yznahmad 2025-07-24 12:39:15 +03:00
parent 3bd8e3a5ce
commit 9131588936
60 changed files with 22996 additions and 0 deletions

71
.dockerignore Normal file
View File

@ -0,0 +1,71 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production build
/build
/public/build
# Environment files
.env
.env.local
.env.development
.env.test
.env.production
# Database files
*.db
*.db-journal
/data
/backups
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# IDE files
.vscode
.idea
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Documentation
README.md
*.md
# Scripts
deploy.sh
*.sh
# Temporary files
tmp
temp

38
.env.production Normal file
View File

@ -0,0 +1,38 @@
# Production Environment Variables
# Copy this file and rename to .env for production deployment
# Make sure to change all default values for security
# Application Settings
NODE_ENV=production
APP_PORT=3000
DOMAIN=your-domain.com
# Database
DATABASE_URL="file:/app/data/production.db"
# Security
SESSION_SECRET="your-super-secure-session-secret-change-this-in-production-min-32-chars"
# Super Admin Account (created on first run)
SUPER_ADMIN="superadmin"
SUPER_ADMIN_EMAIL="admin@yourcompany.com"
SUPER_ADMIN_PASSWORD="YourSecurePassword123!"
# Storage Paths (for bind mounts)
DATA_PATH=./data
BACKUP_PATH=./backups
# Backup Schedule (cron format)
BACKUP_SCHEDULE="0 2 * * *"
# Mail Settings (optional - for password reset features)
MAIL_HOST=""
MAIL_PORT="587"
MAIL_SECURE="false"
MAIL_USERNAME=""
MAIL_PASSWORD=""
MAIL_FROM_NAME="Phosphat Report System"
MAIL_FROM_EMAIL=""
# Logging (optional)
LOG_LEVEL="info"

84
.eslintrc.cjs Normal file
View File

@ -0,0 +1,84 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ["!**/.server", "!**/.client"],
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
"import/resolver": {
typescript: {},
},
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
},
// Node
{
files: [".eslintrc.cjs"],
env: {
node: true,
},
},
],
};

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
/.cache
/build
.env
/generated/prisma
/generated/prisma

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.autoClosingTags": false
}

236
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,236 @@
# Phosphat Report - Production Deployment Guide
This guide will help you deploy the Phosphat Report application on your Dockploy VPS using Docker Compose.
## Prerequisites
- Docker and Docker Compose installed on your VPS
- Domain name configured (optional but recommended)
- SSL certificate (handled by Traefik if using reverse proxy)
## Quick Start
1. **Clone the repository** to your VPS:
```bash
git clone <your-repo-url>
cd phosphat-report
```
2. **Configure environment variables**:
```bash
cp .env.production .env
nano .env # Edit with your production values
```
3. **Deploy the application**:
```bash
chmod +x deploy.sh
./deploy.sh deploy
```
## Environment Configuration
Edit the `.env` file with your production values:
### Required Settings
```env
# Change these values for security
SESSION_SECRET="your-super-secure-session-secret-min-32-chars"
SUPER_ADMIN_PASSWORD="YourSecurePassword123!"
SUPER_ADMIN_EMAIL="admin@yourcompany.com"
# Your domain (for Traefik labels)
DOMAIN=your-domain.com
```
### Optional Settings
```env
# Custom port (default: 3000)
APP_PORT=3000
# Storage paths
DATA_PATH=./data
BACKUP_PATH=./backups
# Email configuration (for password reset)
MAIL_HOST=smtp.gmail.com
MAIL_USERNAME=your-email@gmail.com
MAIL_PASSWORD=your-app-password
```
## Deployment Commands
The `deploy.sh` script provides several useful commands:
```bash
# Deploy application
./deploy.sh deploy
# Stop services
./deploy.sh stop
# Restart services
./deploy.sh restart
# View logs
./deploy.sh logs
# Create database backup
./deploy.sh backup
# Check status
./deploy.sh status
```
## Manual Deployment
If you prefer manual deployment:
```bash
# Create directories
mkdir -p data backups logs
# Build and start services
docker-compose up -d --build
# Check status
docker-compose ps
docker-compose logs app
```
## Dockploy Integration
For Dockploy deployment:
1. **Create a new application** in Dockploy
2. **Set the repository** URL
3. **Configure environment variables** in Dockploy UI
4. **Set build command**: `docker-compose build`
5. **Set start command**: `docker-compose up -d`
### Dockploy Environment Variables
Add these in the Dockploy environment variables section:
```
NODE_ENV=production
SESSION_SECRET=your-super-secure-session-secret
SUPER_ADMIN=superadmin
SUPER_ADMIN_EMAIL=admin@yourcompany.com
SUPER_ADMIN_PASSWORD=YourSecurePassword123!
DOMAIN=your-domain.com
```
## Database Management
### Backup
```bash
# Manual backup
docker-compose exec app cp /app/data/production.db /app/data/backup_$(date +%Y%m%d_%H%M%S).db
# Automated backup (runs daily at 2 AM)
# Configured in docker-compose.yml backup service
```
### Restore
```bash
# Stop application
docker-compose stop app
# Restore database
cp backups/backup_YYYYMMDD_HHMMSS.db data/production.db
# Start application
docker-compose start app
```
## Monitoring
### Health Check
```bash
curl http://localhost:3000/health
```
### Logs
```bash
# Application logs
docker-compose logs -f app
# All services logs
docker-compose logs -f
```
### Resource Usage
```bash
docker stats phosphat-report-app
```
## Reverse Proxy (Traefik)
The application includes Traefik labels for automatic SSL and routing. If you're using Traefik:
1. Ensure Traefik is running on your server
2. Set the `DOMAIN` environment variable
3. The application will be automatically available at `https://your-domain.com`
## Security Considerations
1. **Change default passwords** in `.env`
2. **Use strong session secret** (minimum 32 characters)
3. **Enable firewall** on your VPS
4. **Regular backups** are configured automatically
5. **Keep Docker images updated**
## Troubleshooting
### Application won't start
```bash
# Check logs
docker-compose logs app
# Check database permissions
ls -la data/
# Rebuild without cache
docker-compose build --no-cache
```
### Database issues
```bash
# Reset database (WARNING: This will delete all data)
docker-compose down
rm -f data/production.db
docker-compose up -d
```
### Performance issues
```bash
# Check resource usage
docker stats
# Increase memory limits in docker-compose.yml
# Optimize database queries
```
## Updates
To update the application:
```bash
# Pull latest code
git pull origin main
# Rebuild and restart
docker-compose up -d --build
# Check logs
docker-compose logs -f app
```
## Support
For issues and support:
1. Check the logs: `docker-compose logs app`
2. Verify environment variables
3. Check database connectivity
4. Review health endpoint: `/health`

87
Dockerfile Normal file
View File

@ -0,0 +1,87 @@
# Use Node.js 20 Alpine for smaller image size
FROM node:20-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
libc6-compat \
openssl \
sqlite \
wget \
dumb-init
# Set working directory
WORKDIR /app
# Install dependencies only when needed
FROM base AS deps
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci --only=production --frozen-lockfile && npm cache clean --force
# Rebuild the source code only when needed
FROM base AS builder
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci --frozen-lockfile
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Production image, copy all the files and run the app
FROM base AS runner
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 remix
# Copy built application
COPY --from=builder --chown=remix:nodejs /app/build ./build
COPY --from=builder --chown=remix:nodejs /app/public ./public
COPY --from=builder --chown=remix:nodejs /app/package.json ./package.json
COPY --from=builder --chown=remix:nodejs /app/prisma ./prisma
# Copy production dependencies
COPY --from=deps --chown=remix:nodejs /app/node_modules ./node_modules
# Create necessary directories
RUN mkdir -p /app/data /app/logs && \
chown -R remix:nodejs /app/data /app/logs
# Create startup script
COPY --chown=remix:nodejs <<EOF /app/start.sh
#!/bin/sh
set -e
echo "Starting Phosphat Report Application..."
# Run database migrations and seed
echo "Running database setup..."
npx prisma db push --accept-data-loss
npx prisma db seed
echo "Database setup complete. Starting application..."
exec npm start
EOF
RUN chmod +x /app/start.sh
USER remix
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
# Health check with wget (more reliable than node)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/start.sh"]

View File

@ -0,0 +1,221 @@
import { Form, Link, useLoaderData } from "@remix-run/react";
import type { Employee } from "@prisma/client";
interface DashboardLayoutProps {
children: React.ReactNode;
user: Pick<Employee, "id" | "name" | "username" | "authLevel">;
}
export default function DashboardLayout({ children, user }: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-full mx-auto px-0 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<img
className="h-12 w-auto justify-self-start"
src="/clogo-sm.png"
alt="Phosphat Report"
/>
{/* <div className="ml-4">
<h1 className="text-xl font-semibold text-gray-900">
Phosphat Report Dashboard
</h1>
</div> */}
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="text-sm text-gray-700">
Welcome, <span className="font-medium">{user.name}</span>
</div>
{/* <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.authLevel === 1
? 'bg-red-100 text-red-800'
: user.authLevel === 2
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}>
Level {user.authLevel}
</span> */}
</div>
<Form method="post" action="/logout">
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 shadow-sm transition duration-150 ease-in-out"
>
<svg className="-ml-0.5 mr-2 h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</button>
</Form>
</div>
</div>
</div>
</nav>
{/* Sidebar */}
<div className="flex">
<div className="w-64 bg-white shadow-sm min-h-screen">
<nav className="mt-8 px-4">
<ul className="space-y-2">
<li>
<Link
to="/dashboard"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
</svg>
Dashboard
</Link>
</li>
<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>
Reports
</Link>
</li>
{(user.authLevel >= 2) && (
<>
{/* Management Section */}
<li className="mt-6">
<div className="px-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Management
</div>
</li>
<li>
<Link
to="/areas"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Areas
</Link>
</li>
<li>
<Link
to="/dredger-locations"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
Dredger Locations
</Link>
</li>
<li>
<Link
to="/reclamation-locations"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Reclamation Locations
</Link>
</li>
<li>
<Link
to="/equipment"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Equipment
</Link>
</li>
<li>
<Link
to="/foreman"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Foreman
</Link>
</li>
<li>
<Link
to="/employees"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
Employees
</Link>
</li>
</>
)}
{user.authLevel === 3 && (
<>
{/* Admin Section */}
<li className="mt-6">
<div className="px-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Administration
</div>
</li>
<li>
<Link
to="/mail-settings"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Mail Settings
</Link>
</li>
<li>
<Link
to="/test-email"
className="group flex items-center px-2 py-2 text-base font-medium rounded-md text-gray-900 hover:bg-gray-50"
>
<svg className="mr-4 h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Test Email
</Link>
</li>
</>
)}
</ul>
</nav>
</div>
{/* Main content */}
<div className="flex-1 p-8">
{children}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
import { Form } from "@remix-run/react";
import { useEffect, useRef } from "react";
interface FormModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
isSubmitting?: boolean;
submitText?: string;
onSubmit?: () => void;
}
export default function FormModal({
isOpen,
onClose,
title,
children,
isSubmitting = false,
submitText = "Save",
onSubmit
}: FormModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={onClose}
/>
{/* Modal panel */}
<div
ref={modalRef}
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6 animate-slide-up"
>
<div className="sm:flex sm:items-start">
<div className="w-full mt-3 text-center sm:mt-0 sm:text-left">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{title}
</h3>
<button
type="button"
onClick={onClose}
className="rounded-md text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-4">
{children}
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="submit"
form="modal-form"
disabled={isSubmitting}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</>
) : (
submitText
)}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,753 @@
import React from 'react';
import { Form } from "@remix-run/react";
interface ReportFormModalProps {
isOpen: boolean;
onClose: () => void;
isEditing: boolean;
isSubmitting: boolean;
editingReport: any;
actionData: any;
areas: any[];
dredgerLocations: any[];
reclamationLocations: any[];
foremen: any[];
equipment: any[];
timeSheetEntries: any[];
stoppageEntries: any[];
addTimeSheetEntry: () => void;
removeTimeSheetEntry: (id: string) => void;
updateTimeSheetEntry: (id: string, field: string, value: string) => void;
addStoppageEntry: () => void;
removeStoppageEntry: (id: string) => void;
updateStoppageEntry: (id: string, field: string, value: string) => void;
}
export default function ReportFormModal({
isOpen,
onClose,
isEditing,
isSubmitting,
editingReport,
actionData,
areas,
dredgerLocations,
reclamationLocations,
foremen,
equipment,
timeSheetEntries,
stoppageEntries,
addTimeSheetEntry,
removeTimeSheetEntry,
updateTimeSheetEntry,
addStoppageEntry,
removeStoppageEntry,
updateStoppageEntry
}: ReportFormModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-6xl sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-gray-900">
{isEditing ? "Edit Report" : "Create New Report"}
</h3>
<button
type="button"
onClick={onClose}
className="bg-white rounded-md text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Form method="post" id="modal-form" className="space-y-8 max-h-[80vh] overflow-y-auto pr-2">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingReport?.id} />}
{/* Hidden inputs for dynamic arrays */}
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
<BasicInformation
editingReport={editingReport}
actionData={actionData}
areas={areas}
dredgerLocations={dredgerLocations}
reclamationLocations={reclamationLocations}
/>
<ReclamationHeight editingReport={editingReport} />
<PipelineLength editingReport={editingReport} />
<EquipmentStatistics
editingReport={editingReport}
foremen={foremen}
/>
<TimeSheetSection
timeSheetEntries={timeSheetEntries}
equipment={equipment}
addTimeSheetEntry={addTimeSheetEntry}
removeTimeSheetEntry={removeTimeSheetEntry}
updateTimeSheetEntry={updateTimeSheetEntry}
/>
<StoppagesSection
stoppageEntries={stoppageEntries}
addStoppageEntry={addStoppageEntry}
removeStoppageEntry={removeStoppageEntry}
updateStoppageEntry={updateStoppageEntry}
/>
<NotesSection editingReport={editingReport} />
</Form>
</div>
{/* Enhanced Modal Footer */}
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse border-t border-gray-200">
<button
type="submit"
form="modal-form"
disabled={isSubmitting}
className="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-6 py-3 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{isEditing ? "Updating..." : "Creating..."}
</>
) : (
<>
<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="M5 13l4 4L19 7" />
</svg>
{isEditing ? "Update Report" : "Create Report"}
</>
)}
</button>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="mt-3 w-full inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-6 py-3 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}
// Basic Information Component
function BasicInformation({ editingReport, actionData, areas, dredgerLocations, reclamationLocations }: any) {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">Basic Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
Shift
</label>
<select
name="shift"
id="shift"
required
defaultValue={editingReport?.shift || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select shift</option>
<option value="day">Day Shift</option>
<option value="night">Night Shift</option>
</select>
{actionData?.errors?.shift && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.shift}</p>
)}
</div>
<div>
<label htmlFor="areaId" className="block text-sm font-medium text-gray-700 mb-2">
Area
</label>
<select
name="areaId"
id="areaId"
required
defaultValue={editingReport?.areaId || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select area</option>
{areas.map((area: any) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
{actionData?.errors?.areaId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.areaId}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="dredgerLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Dredger Location
</label>
<select
name="dredgerLocationId"
id="dredgerLocationId"
required
defaultValue={editingReport?.dredgerLocationId || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select dredger location</option>
{dredgerLocations.map((location: any) => (
<option key={location.id} value={location.id}>
{location.name} ({location.class.toUpperCase()})
</option>
))}
</select>
{actionData?.errors?.dredgerLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLocationId}</p>
)}
</div>
<div>
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Reclamation Location
</label>
<select
name="reclamationLocationId"
id="reclamationLocationId"
required
defaultValue={editingReport?.reclamationLocationId || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select reclamation location</option>
{reclamationLocations.map((location: any) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
{actionData?.errors?.reclamationLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
Dredger Line Length (m)
</label>
<input
type="number"
name="dredgerLineLength"
id="dredgerLineLength"
min="0"
required
defaultValue={editingReport?.dredgerLineLength || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter length in meters"
/>
{actionData?.errors?.dredgerLineLength && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
)}
</div>
<div>
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">
Shore Connection (m)
</label>
<input
type="number"
name="shoreConnection"
id="shoreConnection"
min="0"
required
defaultValue={editingReport?.shoreConnection || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter connection length"
/>
{actionData?.errors?.shoreConnection && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.shoreConnection}</p>
)}
</div>
</div>
</div>
);
}
// Reclamation Height Component
function ReclamationHeight({ editingReport }: any) {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">Reclamation Height</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="reclamationHeightBase" className="block text-sm font-medium text-gray-700 mb-2">
Base Height (m)
</label>
<input
type="number"
name="reclamationHeightBase"
id="reclamationHeightBase"
min="0"
defaultValue={editingReport?.reclamationHeight?.base || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="reclamationHeightExtra" className="block text-sm font-medium text-gray-700 mb-2">
Extra Height (m)
</label>
<input
type="number"
name="reclamationHeightExtra"
id="reclamationHeightExtra"
min="0"
defaultValue={editingReport?.reclamationHeight?.extra || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
</div>
);
}
// Pipeline Length Component
function PipelineLength({ editingReport }: any) {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">Pipeline Length</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="pipelineMain" className="block text-sm font-medium text-gray-700 mb-2">
Main (m)
</label>
<input
type="number"
name="pipelineMain"
id="pipelineMain"
min="0"
defaultValue={editingReport?.pipelineLength?.main || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="pipelineExt1" className="block text-sm font-medium text-gray-700 mb-2">
Ext1 (m)
</label>
<input
type="number"
name="pipelineExt1"
id="pipelineExt1"
min="0"
defaultValue={editingReport?.pipelineLength?.ext1 || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="pipelineReserve" className="block text-sm font-medium text-gray-700 mb-2">
Reserve (m)
</label>
<input
type="number"
name="pipelineReserve"
id="pipelineReserve"
min="0"
defaultValue={editingReport?.pipelineLength?.reserve || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="pipelineExt2" className="block text-sm font-medium text-gray-700 mb-2">
Ext2 (m)
</label>
<input
type="number"
name="pipelineExt2"
id="pipelineExt2"
min="0"
defaultValue={editingReport?.pipelineLength?.ext2 || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
</div>
);
}
// Equipment Statistics Component
function EquipmentStatistics({ editingReport, foremen }: any) {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">Equipment Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">
Dozers
</label>
<input
type="number"
name="statsDozers"
id="statsDozers"
min="0"
defaultValue={editingReport?.stats?.Dozers || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">
Excavators
</label>
<input
type="number"
name="statsExc"
id="statsExc"
min="0"
defaultValue={editingReport?.stats?.Exc || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">
Loaders
</label>
<input
type="number"
name="statsLoaders"
id="statsLoaders"
min="0"
defaultValue={editingReport?.stats?.Loaders || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="statsLaborer" className="block text-sm font-medium text-gray-700 mb-2">
Laborers
</label>
<input
type="number"
name="statsLaborer"
id="statsLaborer"
min="0"
defaultValue={editingReport?.stats?.Laborer || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">
Foreman
</label>
<select
name="statsForeman"
id="statsForeman"
defaultValue={editingReport?.stats?.Foreman || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select foreman</option>
{foremen.map((foreman: any) => (
<option key={foreman.id} value={foreman.name}>
{foreman.name}
</option>
))}
</select>
</div>
</div>
</div>
);
}
// TimeSheet Section Component
function TimeSheetSection({ timeSheetEntries, equipment, addTimeSheetEntry, removeTimeSheetEntry, updateTimeSheetEntry }: any) {
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900 flex items-center">
<svg className="w-6 h-6 mr-2 text-blue-600" 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>
Equipments Time Sheet
</h3>
<p className="text-sm text-gray-600 mt-1">Track Equipment working hours and maintenance periods</p>
</div>
<button
type="button"
onClick={addTimeSheetEntry}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200 shadow-sm"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Equipment Entry
</button>
</div>
{timeSheetEntries.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" 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>
<p className="text-lg font-medium">No Equipment entries yet</p>
<p className="text-sm">Click "Add Equipment Entry" to start tracking Equipment hours</p>
</div>
) : (
<div className="space-y-4 max-h-80 overflow-y-auto">
{timeSheetEntries.map((entry: any, index: number) => (
<div key={entry.id} className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-500">Entry #{index + 1}</span>
<button
type="button"
onClick={() => removeTimeSheetEntry(entry.id)}
className="inline-flex items-center justify-center w-8 h-8 border border-transparent text-sm font-medium rounded-full text-red-600 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">Equipment</label>
<select
value={entry.machine}
onChange={(e) => updateTimeSheetEntry(entry.id, 'machine', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="">Select Equipment</option>
{equipment.map((item: any) => (
<option key={item.id} value={`${item.model} (${item.number})`}>
{item.category} - {item.model} ({item.number})
</option>
))}
</select>
</div>
<div className="lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">Reason</label>
<input
type="text"
value={entry.reason}
onChange={(e) => updateTimeSheetEntry(entry.id, 'reason', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="e.g., Maintenance, Operation"
/>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">From 1</label>
<input
type="time"
value={entry.from1}
onChange={(e) => updateTimeSheetEntry(entry.id, 'from1', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">To 1</label>
<input
type="time"
value={entry.to1}
onChange={(e) => updateTimeSheetEntry(entry.id, 'to1', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">From 2</label>
<input
type="time"
value={entry.from2}
onChange={(e) => updateTimeSheetEntry(entry.id, 'from2', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">To 2</label>
<input
type="time"
value={entry.to2}
onChange={(e) => updateTimeSheetEntry(entry.id, 'to2', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Total Hours</label>
<div className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-sm font-medium text-gray-900">
{entry.total}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
// Stoppages Section Component
function StoppagesSection({ stoppageEntries, addStoppageEntry, removeStoppageEntry, updateStoppageEntry }: any) {
return (
<div className="bg-gradient-to-r from-red-50 to-orange-50 p-6 rounded-lg border border-red-200">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900 flex items-center">
<svg className="w-6 h-6 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
Operation Stoppages
</h3>
<p className="text-sm text-gray-600 mt-1">Record operational interruptions and downtime</p>
</div>
<button
type="button"
onClick={addStoppageEntry}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 shadow-sm"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Stoppage
</button>
</div>
{stoppageEntries.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-lg font-medium">No stoppages recorded</p>
<p className="text-sm">Click "Add Stoppage" to record operational interruptions</p>
</div>
) : (
<div className="space-y-4 max-h-80 overflow-y-auto">
{stoppageEntries.map((entry: any, index: number) => (
<div key={entry.id} className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-500">Stoppage #{index + 1}</span>
<button
type="button"
onClick={() => removeStoppageEntry(entry.id)}
className="inline-flex items-center justify-center w-8 h-8 border border-transparent text-sm font-medium rounded-full text-red-600 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">From</label>
<input
type="time"
value={entry.from}
onChange={(e) => updateStoppageEntry(entry.id, 'from', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">To</label>
<input
type="time"
value={entry.to}
onChange={(e) => updateStoppageEntry(entry.id, 'to', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Total Duration</label>
<div className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-sm font-medium text-gray-900">
{entry.total}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Reason</label>
<input
type="text"
value={entry.reason}
onChange={(e) => updateStoppageEntry(entry.id, 'reason', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-sm"
placeholder="e.g., Maintenance, Equipment failure"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Responsible</label>
<input
type="text"
value={entry.responsible}
onChange={(e) => updateStoppageEntry(entry.id, 'responsible', e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-sm"
placeholder="e.g., Maintenance team"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Additional Notes</label>
<textarea
value={entry.note}
onChange={(e) => updateStoppageEntry(entry.id, 'note', e.target.value)}
rows={2}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-sm"
placeholder="Additional details about the stoppage..."
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
// Notes Section Component
function NotesSection({ editingReport }: any) {
return (
<div className="bg-gradient-to-r from-gray-50 to-slate-50 p-6 rounded-lg border border-gray-200">
<h3 className="text-xl font-semibold text-gray-900 flex items-center mb-4">
<svg className="w-6 h-6 mr-2 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Additional Notes
</h3>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-3">
Report Notes & Observations
</label>
<textarea
name="notes"
id="notes"
rows={5}
defaultValue={editingReport?.notes || ""}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm resize-none"
placeholder="Enter any additional notes, observations, or important details about this shift..."
/>
</div>
</div>
);
}

View File

@ -0,0 +1,407 @@
import React from 'react';
import { exportReportToExcel } from '~/utils/excelExport';
interface ReportViewModalProps {
isOpen: boolean;
onClose: () => void;
report: any;
}
export default function ReportViewModal({ isOpen, onClose, report }: ReportViewModalProps) {
if (!isOpen || !report) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full">
<div className="bg-white px-6 pt-6 pb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">Report View</h3>
<div className="flex space-x-2">
<button
type="button"
onClick={() => exportReportToExcel(report).catch(console.error)}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Export Excel
</button>
<button
type="button"
onClick={() => window.print()}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Print
</button>
<button
type="button"
onClick={onClose}
className="bg-white rounded-md text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* ISO Standard Report Layout */}
<div className="max-h-[80vh] overflow-y-auto bg-white p-6 border border-gray-300" style={{ fontFamily: 'Arial, sans-serif' }}>
<ReportHeader />
<ReportInfo report={report} />
<ReportDredgerSection report={report} />
<ReportLocationData report={report} />
<ReportPipelineLength report={report} />
<ReportShiftHeader report={report} />
<ReportEquipmentStats report={report} />
<ReportTimeSheet report={report} />
<ReportStoppages report={report} />
<ReportNotes report={report} />
<ReportFooter />
</div>
</div>
</div>
</div>
</div>
);
}
// Header Section Component
function ReportHeader() {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="border-r-2 border-black p-2 text-center font-bold text-lg" style={{ width: '70%' }}>
<div>Reclamation Work Diary</div>
<div className="border-t border-black mt-1 pt-1">QF-3.6.1-08</div>
<div className="border-t border-black mt-1 pt-1">Rev. 1.0</div>
</td>
<td className="p-2 text-center" style={{ width: '30%' }}>
<img
src="/logo-light.png"
alt="Arab Potash Logo"
className="h-16 mx-auto"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</td>
</tr>
</tbody>
</table>
</div>
);
}
// Report Info Component
function ReportInfo({ report }: { report: any }) {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="border-r border-black p-2 font-semibold" style={{ width: '15%' }}>Date:</td>
<td className="border-r border-black p-2" style={{ width: '35%' }}>
{new Date(report.createdDate).toLocaleDateString('en-GB')}
</td>
<td className="border-r border-black p-2 font-semibold" style={{ width: '20%' }}>Report No.</td>
<td className="p-2" style={{ width: '30%' }}>{report.id}</td>
</tr>
</tbody>
</table>
</div>
);
}
// Dredger Section Component
function ReportDredgerSection({ report }: { report: any }) {
return (
<div className="text-center font-bold text-lg mb-2 underline">
{report.area.name} Dredger
</div>
);
}
// Location Data Component
function ReportLocationData({ report }: { report: any }) {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse text-sm">
<tbody>
<tr>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black" style={{ width: '25%' }}>
Dredger Location
</td>
<td className="p-2 border-r border-black text-center" style={{ width: '25%' }}>
{report.dredgerLocation.name}
</td>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black" style={{ width: '25%' }}>
Dredger Line Length
</td>
<td className="p-2 text-center" style={{ width: '25%' }}>
{report.dredgerLineLength}
</td>
</tr>
<tr>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black border-t border-black">
Reclamation Location
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.reclamationLocation.name}
</td>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black border-t border-black">
Shore Connection
</td>
<td className="p-2 border-t border-black text-center">
{report.shoreConnection}
</td>
</tr>
<tr>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black border-t border-black">
Reclamation Height
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.reclamationHeight?.base || 0}m - {(report.reclamationHeight?.extra + report.reclamationHeight?.base || 0 || 0)}m
</td>
<td className="p-2 border-r border-black border-t border-black" colSpan={2}></td>
</tr>
</tbody>
</table>
</div>
);
}
// Pipeline Length Component
function ReportPipelineLength({ report }: { report: any }) {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse text-sm">
<tbody>
<tr>
<td className="bg-green-500 text-white p-2 font-semibold border-r border-black" rowSpan={2}>
Pipeline Length "from Shore Connection"
</td>
<td className="p-2 border-r border-black text-center font-semibold">Main</td>
<td className="p-2 border-r border-black text-center font-semibold">extension</td>
<td className="p-2 border-r border-black text-center font-semibold">total</td>
<td className="p-2 border-r border-black text-center font-semibold">Reserve</td>
<td className="p-2 border-r border-black text-center font-semibold">extension</td>
<td className="p-2 text-center font-semibold">total</td>
</tr>
<tr>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.pipelineLength?.main || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.pipelineLength?.ext1 || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{(report.pipelineLength?.main || 0) + (report.pipelineLength?.ext1 || 0)}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.pipelineLength?.reserve || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.pipelineLength?.ext2 || 0}
</td>
<td className="p-2 border-t border-black text-center">
{(report.pipelineLength?.reserve || 0) + (report.pipelineLength?.ext2 || 0)}
</td>
</tr>
</tbody>
</table>
</div>
);
}
// Shift Header Component
function ReportShiftHeader({ report }: { report: any }) {
return (
<div className="bg-green-500 text-white p-2 text-center font-bold mb-2">
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift
</div>
);
}
// Equipment Statistics Component
function ReportEquipmentStats({ report }: { report: any }) {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse text-sm">
<tbody>
<tr>
<td className="p-2 border-r border-black text-center font-semibold">Dozers</td>
<td className="p-2 border-r border-black text-center font-semibold">Exc.</td>
<td className="p-2 border-r border-black text-center font-semibold">Loader</td>
<td className="p-2 border-r border-black text-center font-semibold">Foreman</td>
<td className="p-2 text-center font-semibold">Laborer</td>
</tr>
<tr>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.stats?.Dozers || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.stats?.Exc || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.stats?.Loaders || 0}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{report.stats?.Foreman || ''}
</td>
<td className="p-2 border-t border-black text-center">
{report.stats?.Laborer || 0}
</td>
</tr>
</tbody>
</table>
</div>
);
}
// Time Sheet Component
function ReportTimeSheet({ report }: { report: any }) {
return (
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<td className="p-2 border-r border-black text-center font-semibold" rowSpan={2}>Time Sheet</td>
<td className="p-2 border-r border-black text-center font-semibold">From</td>
<td className="p-2 border-r border-black text-center font-semibold">To</td>
<td className="p-2 border-r border-black text-center font-semibold">From</td>
<td className="p-2 border-r border-black text-center font-semibold">To</td>
<td className="p-2 border-r border-black text-center font-semibold">Total</td>
<td className="p-2 text-center font-semibold">Reason</td>
</tr>
</thead>
<tbody>
{Array.isArray(report.timeSheet) && report.timeSheet.length > 0 ? (
report.timeSheet.map((entry: any, index: number) => (
<tr key={index}>
<td className="p-2 border-r border-black border-t border-black font-semibold">
{entry.machine}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.from1}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.to1}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.from2}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.to2}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.total}
</td>
<td className="p-2 border-t border-black">
{entry.reason}
</td>
</tr>
))
) : (
<tr>
<td className="p-2 border-r border-black border-t border-black text-center" colSpan={7}>
No time sheet entries
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// Stoppages Component
function ReportStoppages({ report }: { report: any }) {
return (
<>
<div className="bg-green-500 text-white p-2 text-center font-bold mb-2">
Dredger Stoppages
</div>
<div className="border-2 border-black mb-4">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<td className="p-2 border-r border-black text-center font-semibold">From</td>
<td className="p-2 border-r border-black text-center font-semibold">To</td>
<td className="p-2 border-r border-black text-center font-semibold">Total</td>
<td className="p-2 border-r border-black text-center font-semibold">Reason</td>
<td className="p-2 border-r border-black text-center font-semibold">Responsible</td>
<td className="p-2 text-center font-semibold">Notes</td>
</tr>
</thead>
<tbody>
{Array.isArray(report.stoppages) && report.stoppages.length > 0 ? (
report.stoppages.map((entry: any, index: number) => (
<tr key={index}>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.from}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.to}
</td>
<td className="p-2 border-r border-black border-t border-black text-center">
{entry.total}
</td>
<td className="p-2 border-r border-black border-t border-black">
{entry.reason}
</td>
<td className="p-2 border-r border-black border-t border-black">
{entry.responsible}
</td>
<td className="p-2 border-t border-black">
{entry.note}
</td>
</tr>
))
) : (
<tr>
<td className="p-2 border-r border-black border-t border-black text-center" colSpan={6}>
No stoppages recorded
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
);
}
// Notes Component
function ReportNotes({ report }: { report: any }) {
return (
<>
<div className="bg-green-500 text-white p-2 text-center font-bold mb-2">
Notes & Comments
</div>
<div className="border-2 border-black mb-4 min-h-[100px]">
<div className="p-4 text-center">
{report.notes || 'No additional notes'}
</div>
</div>
</>
);
}
// Footer Component
function ReportFooter() {
return (
<div className="text-center text-sm mt-4 border-t border-black pt-2">
{/* موقعة لأعمال الصيانة */}
</div>
);
}

56
app/components/Toast.tsx Normal file
View File

@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
interface ToastProps {
message: string;
type: "success" | "error";
onClose: () => void;
}
export default function Toast({ message, type, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(true);
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const bgColor = type === "success" ? "bg-green-500" : "bg-red-500";
const icon = type === "success" ? (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
);
return (
<div className="fixed top-4 right-4 z-50">
<div
className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center space-x-3 transform transition-all duration-300 ${
isVisible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
}`}
>
{icon}
<span className="font-medium">{message}</span>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className="ml-4 text-white hover:text-gray-200"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
);
}

18
app/entry.client.tsx Normal file
View File

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

140
app/entry.server.tsx Normal file
View File

@ -0,0 +1,140 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

45
app/root.tsx Normal file
View File

@ -0,0 +1,45 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import "./tailwind.css";
export const links: LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}

11
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getUserId } from "~/utils/auth.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (userId) {
return redirect("/dashboard");
}
return redirect("/signin");
};

283
app/routes/areas.tsx Normal file
View File

@ -0,0 +1,283 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Areas Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const areas = await prisma.area.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { reports: true }
}
}
});
return json({ user, areas });
};
export const action = async ({ request }: ActionFunctionArgs) => {
await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const name = formData.get("name");
if (intent === "create") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
try {
await prisma.area.create({
data: { name }
});
return json({ success: "Area created successfully!" });
} catch (error) {
return json({ errors: { form: "Area name already exists" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid area ID" } }, { status: 400 });
}
try {
await prisma.area.update({
where: { id: parseInt(id) },
data: { name }
});
return json({ success: "Area updated successfully!" });
} catch (error) {
return json({ errors: { form: "Area name already exists" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid area ID" } }, { status: 400 });
}
try {
await prisma.area.delete({
where: { id: parseInt(id) }
});
return json({ success: "Area deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Cannot delete area with existing reports" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function Areas() {
const { user, areas } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingArea, setEditingArea] = useState<{ id: number; name: string } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingArea !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingArea(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (area: { id: number; name: string }) => {
setEditingArea(area);
setShowModal(true);
};
const handleAdd = () => {
setEditingArea(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingArea(null);
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Areas Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage operational areas for your reports</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Area
</button>
</div>
{/* Areas Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Area Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reports Count
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{areas.map((area, index) => (
<tr key={area.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{area.name}</div>
{/* <div className="text-sm text-gray-500">Area #{area.id}</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(area)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={area.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this area?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{areas.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No areas</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating your first area.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Area
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Area" : "Add New Area"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Area" : "Create Area"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingArea?.id} />}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Area Name
</label>
<input
type="text"
name="name"
id="name"
required
defaultValue={editingArea?.name || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter area name"
/>
{actionData?.errors?.name && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
)}
</div>
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1,26 @@
import { Link } from "@remix-run/react";
export default function DashboardCatchAll() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Page Not Found
</h2>
<p className="mt-2 text-sm text-gray-600">
The page you're looking for doesn't exist.
</p>
<div className="mt-6">
<Link
to="/dashboard"
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"
>
Back to Dashboard
</Link>
</div>
</div>
</div>
</div>
);
}

183
app/routes/dashboard.tsx Normal file
View File

@ -0,0 +1,183 @@
import type { LoaderFunctionArgs, MetaFunction } 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 { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Dashboard - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1);
// Get dashboard statistics
const [reportCount, equipmentCount, areaCount] = await Promise.all([
prisma.report.count(),
prisma.equipment.count(),
prisma.area.count(),
]);
// Get recent reports
const recentReports = await prisma.report.findMany({
take: 5,
orderBy: { createdDate: 'desc' },
include: {
employee: { select: { name: true } },
area: { select: { name: true } },
},
});
return json({
user,
stats: {
reportCount,
equipmentCount,
areaCount,
},
recentReports,
});
};
export default function Dashboard() {
const { user, stats, recentReports } = useLoaderData<typeof loader>();
return (
<DashboardLayout user={user}>
<div className="space-y-6">
{/* Welcome Section */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Welcome back, {user.name}!
</h2>
<p className="text-gray-600">
Here's what's happening with your phosphat operations today.
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Total Reports
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.reportCount}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Equipment Units
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.equipmentCount}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Active Areas
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.areaCount}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Recent Reports */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Recent Reports
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Latest activity from your team
</p>
</div>
<ul className="divide-y divide-gray-200">
{recentReports.length > 0 ? (
recentReports.map((report) => (
<li key={report.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-indigo-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{report.employee.name.charAt(0)}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{report.employee.name}
</div>
<div className="text-sm text-gray-500">
{report.area.name} - {report.shift} shift
</div>
</div>
</div>
<div className="text-sm text-gray-500">
{new Date(report.createdDate).toLocaleDateString()}
</div>
</div>
</div>
</li>
))
) : (
<li>
<div className="px-4 py-4 sm:px-6 text-center text-gray-500">
No reports yet. Create your first report to get started!
</div>
</li>
)}
</ul>
</div>
</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1,381 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Dredger Locations Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const dredgerLocations = await prisma.dredgerLocation.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { reports: true }
}
}
});
return json({ user, dredgerLocations });
};
export const action = async ({ request }: ActionFunctionArgs) => {
await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const name = formData.get("name");
const classType = formData.get("class");
if (intent === "create") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
// if (typeof classType !== "string" || !["s", "d", "sp"].includes(classType)) {
// return json({ errors: { class: "Valid class is required (s, d, or sp)" } }, { status: 400 });
// }
try {
await prisma.dredgerLocation.create({
data: { name, class: classType }
});
return json({ success: "Dredger location created successfully!" });
} catch (error) {
return json({ errors: { form: "Dredger location name already exists" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof classType !== "string" || !["s", "d", "sp"].includes(classType)) {
return json({ errors: { class: "Valid class is required (s, d, or sp)" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid dredger location ID" } }, { status: 400 });
}
try {
await prisma.dredgerLocation.update({
where: { id: parseInt(id) },
data: { name, class: classType }
});
return json({ success: "Dredger location updated successfully!" });
} catch (error) {
return json({ errors: { form: "Dredger location name already exists" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid dredger location ID" } }, { status: 400 });
}
try {
await prisma.dredgerLocation.delete({
where: { id: parseInt(id) }
});
return json({ success: "Dredger location deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Cannot delete dredger location with existing reports" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function DredgerLocations() {
const { user, dredgerLocations } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingLocation, setEditingLocation] = useState<{ id: number; name: string; class: string } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingLocation !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingLocation(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (location: { id: number; name: string; class: string }) => {
setEditingLocation(location);
setShowModal(true);
};
const handleAdd = () => {
setEditingLocation(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingLocation(null);
};
const getClassBadge = (classType: string) => {
const colors = {
C: "bg-blue-100 text-blue-800",
D: "bg-green-100 text-green-800",
SD: "bg-red-100 text-red-800",
SP: "bg-purple-100 text-purple-800",
PC: "bg-yellow-100 text-yellow-800",
SOPA: "bg-indigo-100 text-indigo-800"
};
return colors[classType as keyof typeof colors] || "bg-gray-100 text-gray-800";
};
const getClassIcon = (classType: string) => {
switch (classType) {
case "C":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <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>
// );
// case "D":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
// </svg>
// );
// case "SP":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
// </svg>
// );
// case "SD":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
// </svg>
// );
// case "PC":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
// </svg>
// );
// case "SOPA":
// return (
// <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
// </svg>
// );
default:
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
);
}
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dredger Locations Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage dredger locations with different classes</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Location
</button>
</div>
{/* Locations Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Location
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Class
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reports Count
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{dredgerLocations.map((location) => (
<tr key={location.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getClassBadge(location.class)}`}>
{getClassIcon(location.class)}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{location.name}</div>
{/* <div className="text-sm text-gray-500">ID #{location.id}</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getClassBadge(location.class)}`}>
{location.class.toUpperCase()} - {location.class === 's' ? 'Standard' : location.class === 'd' ? 'Deep' : 'Special'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{location._count.reports} reports
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(location)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={location.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this dredger location?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{dredgerLocations.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No dredger locations</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating your first dredger location.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Location
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Dredger Location" : "Add New Dredger Location"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Location" : "Create Location"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingLocation?.id} />}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Location Name
</label>
<input
type="text"
name="name"
id="name"
required
defaultValue={editingLocation?.name || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter location name"
/>
{actionData?.errors?.name && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="class" className="block text-sm font-medium text-gray-700 mb-2">
Class Type
</label>
<select
name="class"
id="class"
required
defaultValue={editingLocation?.class || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select class type</option>
<option value="C">C - Special</option>
<option value="D">D - Special</option>
<option value="SD">SD - Standard</option>
<option value="SP">SP - Standard</option>
<option value="PC">PC - Deep</option>
<option value="SOPA">SOPA - Deep</option>
{/* <option value="sp">SP - Special</option> */}
</select>
{actionData?.errors?.class && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.class}</p>
)}
</div>
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

549
app/routes/employees.tsx Normal file
View File

@ -0,0 +1,549 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Employee Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
// If user is level 2 (Admin), they can only see employees with level <= 2
// If user is level 3 (Super Admin), they can see all employees
const whereClause = user.authLevel === 2
? { authLevel: { lte: 2 } } // Level 2 users can only see level 1 and 2
: {}; // Level 3 users can see all levels
const employees = await prisma.employee.findMany({
where: whereClause,
orderBy: [{ authLevel: 'asc' }, { name: 'asc' }],
include: {
_count: {
select: { reports: true }
}
}
});
return json({ user, employees });
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const name = formData.get("name");
const username = formData.get("username");
const email = formData.get("email");
const password = formData.get("password");
const authLevel = formData.get("authLevel");
if (intent === "create") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof username !== "string" || username.length === 0) {
return json({ errors: { username: "Username is required" } }, { status: 400 });
}
if (typeof email !== "string" || email.length === 0) {
return json({ errors: { email: "Email is required" } }, { status: 400 });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return json({ errors: { email: "Please enter a valid email address" } }, { status: 400 });
}
if (typeof password !== "string" || password.length < 6) {
return json({ errors: { password: "Password must be at least 6 characters" } }, { status: 400 });
}
if (typeof authLevel !== "string" || !["1", "2", "3"].includes(authLevel)) {
return json({ errors: { authLevel: "Valid auth level is required (1, 2, or 3)" } }, { status: 400 });
}
// Level 2 users cannot create Level 3 employees
if (user.authLevel === 2 && parseInt(authLevel) === 3) {
return json({ errors: { authLevel: "You don't have permission to create Super Admin users" } }, { status: 403 });
}
try {
const hashedPassword = bcrypt.hashSync(password, 10);
await prisma.employee.create({
data: {
name,
username,
email,
password: hashedPassword,
authLevel: parseInt(authLevel)
}
});
return json({ success: "Employee created successfully!" });
} catch (error) {
return json({ errors: { form: "Username or email already exists" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof username !== "string" || username.length === 0) {
return json({ errors: { username: "Username is required" } }, { status: 400 });
}
if (typeof email !== "string" || email.length === 0) {
return json({ errors: { email: "Email is required" } }, { status: 400 });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return json({ errors: { email: "Please enter a valid email address" } }, { status: 400 });
}
if (typeof authLevel !== "string" || !["1", "2", "3"].includes(authLevel)) {
return json({ errors: { authLevel: "Valid auth level is required (1, 2, or 3)" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid employee ID" } }, { status: 400 });
}
// Check if the employee being updated exists and if current user can edit them
const existingEmployee = await prisma.employee.findUnique({
where: { id: parseInt(id) },
select: { authLevel: true }
});
if (!existingEmployee) {
return json({ errors: { form: "Employee not found" } }, { status: 404 });
}
// Level 2 users cannot edit Level 3 employees
if (user.authLevel === 2 && existingEmployee.authLevel === 3) {
return json({ errors: { form: "You don't have permission to edit Super Admin users" } }, { status: 403 });
}
// Level 2 users cannot promote someone to Level 3
if (user.authLevel === 2 && parseInt(authLevel) === 3) {
return json({ errors: { authLevel: "You don't have permission to create Super Admin users" } }, { status: 403 });
}
try {
const updateData: any = {
name,
username,
email,
authLevel: parseInt(authLevel)
};
// Only update password if provided
if (typeof password === "string" && password.length >= 6) {
updateData.password = bcrypt.hashSync(password, 10);
} else if (typeof password === "string" && password.length > 0 && password.length < 6) {
return json({ errors: { password: "Password must be at least 6 characters" } }, { status: 400 });
}
await prisma.employee.update({
where: { id: parseInt(id) },
data: updateData
});
return json({ success: "Employee updated successfully!" });
} catch (error) {
return json({ errors: { form: "Username or email already exists" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid employee ID" } }, { status: 400 });
}
// Check if the employee being deleted exists and if current user can delete them
const existingEmployee = await prisma.employee.findUnique({
where: { id: parseInt(id) },
select: { authLevel: true }
});
if (!existingEmployee) {
return json({ errors: { form: "Employee not found" } }, { status: 404 });
}
// Level 2 users cannot delete Level 3 employees
if (user.authLevel === 2 && existingEmployee.authLevel === 3) {
return json({ errors: { form: "You don't have permission to delete Super Admin users" } }, { status: 403 });
}
try {
await prisma.employee.delete({
where: { id: parseInt(id) }
});
return json({ success: "Employee deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Cannot delete employee with existing reports" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function Employees() {
const { user, employees } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingEmployee, setEditingEmployee] = useState<{ id: number; name: string; username: string; email: string; authLevel: number } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingEmployee !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingEmployee(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (employee: { id: number; name: string; username: string; email: string; authLevel: number }) => {
setEditingEmployee(employee);
setShowModal(true);
};
const handleAdd = () => {
setEditingEmployee(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingEmployee(null);
};
const getAuthLevelBadge = (authLevel: number) => {
const colors = {
1: "bg-yellow-100 text-yellow-800",
2: "bg-green-100 text-green-800",
3: "bg-blue-100 text-blue-800"
};
return colors[authLevel as keyof typeof colors] || "bg-gray-100 text-gray-800";
};
const getAuthLevelText = (authLevel: number) => {
const levels = {
1: "User",
2: "Admin",
3: "Super Admin"
};
return levels[authLevel as keyof typeof levels] || "Unknown";
};
const getAuthLevelIcon = (authLevel: number) => {
switch (authLevel) {
case 1:
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
);
case 2:
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
);
case 3:
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
);
default:
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Employee Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage system users and their access levels</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Employee
</button>
</div>
{/* Employees Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Employee
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Access Level
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reports Count
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{employees.map((employee) => (
<tr key={employee.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getAuthLevelBadge(employee.authLevel)}`}>
{getAuthLevelIcon(employee.authLevel)}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{employee.name}</div>
{/* <div className="text-sm text-gray-500">ID #{employee.id}</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 font-mono">{employee.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{employee.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getAuthLevelBadge(employee.authLevel)}`}>
Level {employee.authLevel} - {getAuthLevelText(employee.authLevel)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{employee._count.reports} reports
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(employee)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
{employee.id !== user.id && (
<Form method="post" className="inline">
<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="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{employees.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No employees</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first employee.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Employee
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Employee" : "Add New Employee"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Employee" : "Create Employee"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingEmployee?.id} />}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Full Name
</label>
<input
type="text"
name="name"
id="name"
required
defaultValue={editingEmployee?.name || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter full name"
/>
{actionData?.errors?.name && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
name="username"
id="username"
required
defaultValue={editingEmployee?.username || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter username"
/>
{actionData?.errors?.username && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.username}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
name="email"
id="email"
required
defaultValue={editingEmployee?.email || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter email address"
/>
{actionData?.errors?.email && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password {isEditing && <span className="text-gray-500">(leave blank to keep current)</span>}
</label>
<input
type="password"
name="password"
id="password"
required={!isEditing}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder={isEditing ? "Enter new password" : "Enter password"}
/>
{actionData?.errors?.password && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
)}
</div>
<div>
<label htmlFor="authLevel" className="block text-sm font-medium text-gray-700 mb-2">
Access Level
</label>
<select
name="authLevel"
id="authLevel"
required
defaultValue={editingEmployee?.authLevel || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select access level</option>
<option value="1">Level 1 - User (Basic Access)</option>
<option value="2">Level 2 - Admin (Management Access)</option>
{user.authLevel === 3 && (
<option value="3">Level 3 - Super Admin (Full Access)</option>
)}
</select>
{actionData?.errors?.authLevel && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
)}
</div>
</div>
{isEditing && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<div className="flex">
<svg className="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Leave password field empty to keep the current password unchanged.
</p>
</div>
</div>
</div>
)}
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

369
app/routes/equipment.tsx Normal file
View File

@ -0,0 +1,369 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Equipment Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const equipment = await prisma.equipment.findMany({
orderBy: [{ category: 'asc' }, { model: 'asc' }]
});
return json({ user, equipment });
};
export const action = async ({ request }: ActionFunctionArgs) => {
await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const category = formData.get("category");
const model = formData.get("model");
const number = formData.get("number");
if (intent === "create") {
if (typeof category !== "string" || category.length === 0) {
return json({ errors: { category: "Category is required" } }, { status: 400 });
}
if (typeof model !== "string" || model.length === 0) {
return json({ errors: { model: "Model is required" } }, { status: 400 });
}
if (typeof number !== "string" || isNaN(parseInt(number)) || parseInt(number) < 1) {
return json({ errors: { number: "Valid number is required" } }, { status: 400 });
}
try {
await prisma.equipment.create({
data: {
category,
model,
number: parseInt(number)
}
});
return json({ success: "Equipment created successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to create equipment" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof category !== "string" || category.length === 0) {
return json({ errors: { category: "Category is required" } }, { status: 400 });
}
if (typeof model !== "string" || model.length === 0) {
return json({ errors: { model: "Model is required" } }, { status: 400 });
}
if (typeof number !== "string" || isNaN(parseInt(number)) || parseInt(number) < 1) {
return json({ errors: { number: "Valid number is required" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid equipment ID" } }, { status: 400 });
}
try {
await prisma.equipment.update({
where: { id: parseInt(id) },
data: {
category,
model,
number: parseInt(number)
}
});
return json({ success: "Equipment updated successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to update equipment" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid equipment ID" } }, { status: 400 });
}
try {
await prisma.equipment.delete({
where: { id: parseInt(id) }
});
return json({ success: "Equipment deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to delete equipment" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function Equipment() {
const { user, equipment } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingEquipment, setEditingEquipment] = useState<{ id: number; category: string; model: string; number: number } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingEquipment !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingEquipment(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (item: { id: number; category: string; model: string; number: number }) => {
setEditingEquipment(item);
setShowModal(true);
};
const handleAdd = () => {
setEditingEquipment(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingEquipment(null);
};
const getCategoryBadge = (category: string) => {
const colors = {
Dozer: "bg-yellow-100 text-yellow-800",
Excavator: "bg-blue-100 text-blue-800",
Loader: "bg-green-100 text-green-800",
Truck: "bg-red-100 text-red-800"
};
return colors[category as keyof typeof colors] || "bg-gray-100 text-gray-800";
};
const getCategoryIcon = (category: string) => {
// Using the same heavy equipment SVG icon with different colors based on category
const color = {
"Dozer": "#f59e0b", // yellow-500
"Excavator": "#3b82f6", // blue-500
"Loader": "#10b981", // green-500
"Truck": "#ef4444", // red-500
}[category] || "#6b7280"; // gray-500 default
return (
<svg className="h-5 w-5" fill="none" stroke={color} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Equipment Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage your fleet equipment and machinery</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Equipment
</button>
</div>
{/* Equipment Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Equipment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Number
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{equipment.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getCategoryBadge(item.category)}`}>
{getCategoryIcon(item.category)}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{item.model}</div>
{/* <div className="text-sm text-gray-500">ID #{item.id}</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryBadge(item.category)}`}>
{item.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="font-mono">{item.number}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(item)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={item.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this equipment?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{equipment.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No equipment</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first equipment.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Equipment
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Equipment" : "Add New Equipment"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Equipment" : "Create Equipment"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingEquipment?.id} />}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
Category
</label>
<select
name="category"
id="category"
required
defaultValue={editingEquipment?.category || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select category</option>
<option value="Dozer">Dozer</option>
<option value="Excavator">Excavator</option>
<option value="Loader">Loader</option>
<option value="Truck">Truck</option>
</select>
{actionData?.errors?.category && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.category}</p>
)}
</div>
<div>
<label htmlFor="number" className="block text-sm font-medium text-gray-700 mb-2">
Number
</label>
<input
type="number"
name="number"
id="number"
min="1"
required
defaultValue={editingEquipment?.number || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter number"
/>
{actionData?.errors?.number && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.number}</p>
)}
</div>
</div>
<div>
<label htmlFor="model" className="block text-sm font-medium text-gray-700 mb-2">
Model
</label>
<input
type="text"
name="model"
id="model"
required
defaultValue={editingEquipment?.model || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter model name"
/>
{actionData?.errors?.model && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.model}</p>
)}
</div>
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

276
app/routes/foreman.tsx Normal file
View File

@ -0,0 +1,276 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Foreman Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const foremen = await prisma.foreman.findMany({
orderBy: { name: 'asc' }
});
return json({ user, foremen });
};
export const action = async ({ request }: ActionFunctionArgs) => {
await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const name = formData.get("name");
if (intent === "create") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
try {
await prisma.foreman.create({
data: { name }
});
return json({ success: "Foreman created successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to create foreman" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid foreman ID" } }, { status: 400 });
}
try {
await prisma.foreman.update({
where: { id: parseInt(id) },
data: { name }
});
return json({ success: "Foreman updated successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to update foreman" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid foreman ID" } }, { status: 400 });
}
try {
await prisma.foreman.delete({
where: { id: parseInt(id) }
});
return json({ success: "Foreman deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to delete foreman" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function Foreman() {
const { user, foremen } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingForeman, setEditingForeman] = useState<{ id: number; name: string } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingForeman !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingForeman(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (foreman: { id: number; name: string }) => {
setEditingForeman(foreman);
setShowModal(true);
};
const handleAdd = () => {
setEditingForeman(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingForeman(null);
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Foreman Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage site foremen and supervisors</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Foreman
</button>
</div>
{/* Foremen Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Foreman
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{foremen.map((foreman) => (
<tr key={foreman.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-indigo-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{foreman.name.charAt(0).toUpperCase()}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{foreman.name}</div>
{/* <div className="text-sm text-gray-500">Site Foreman</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
#{foreman.id}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(foreman)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={foreman.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this foreman?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{foremen.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No foremen</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first foreman.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Foreman
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Foreman" : "Add New Foreman"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Foreman" : "Create Foreman"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingForeman?.id} />}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Foreman Name
</label>
<input
type="text"
name="name"
id="name"
required
defaultValue={editingForeman?.name || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter foreman's full name"
/>
{actionData?.errors?.name && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
)}
</div>
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

37
app/routes/health.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { prisma } from "~/utils/db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Check database connectivity
await prisma.$queryRaw`SELECT 1`;
return json({
status: "ok",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
database: "connected"
}, {
status: 200,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Content-Type": "application/json"
}
});
} catch (error) {
return json({
status: "error",
timestamp: new Date().toISOString(),
error: "Database connection failed",
database: "disconnected"
}, {
status: 503,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Content-Type": "application/json"
}
});
}
};

10
app/routes/logout.tsx Normal file
View File

@ -0,0 +1,10 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { logout } from "~/utils/auth.server";
export const action = async ({ request }: ActionFunctionArgs) => {
return logout(request);
};
export const loader = async ({ request }: ActionFunctionArgs) => {
return logout(request);
};

View File

@ -0,0 +1,266 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { PrismaClient } from "@prisma/client";
import { requireAuthLevel } from "~/utils/auth.server";
import { testEmailConnection } from "~/utils/mail.server";
import DashboardLayout from "~/components/DashboardLayout";
import { useState } from "react";
const prisma = new PrismaClient();
export async function loader({ request }: LoaderFunctionArgs) {
// Require auth level 3 to access mail settings
const user = await requireAuthLevel(request, 3);
const mailSettings = await prisma.mailSettings.findFirst();
return json({ mailSettings, user });
}
export async function action({ request }: ActionFunctionArgs) {
// Require auth level 3 to modify mail settings
await requireAuthLevel(request, 3);
const formData = await request.formData();
const action = formData.get("_action");
if (action === "test") {
const result = await testEmailConnection();
return json(result);
}
const host = formData.get("host") as string;
const port = parseInt(formData.get("port") as string);
const secure = formData.get("secure") === "on";
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const fromName = formData.get("fromName") as string;
const fromEmail = formData.get("fromEmail") as string;
if (!host || !port || !username || !password || !fromName || !fromEmail) {
return json({ error: "All fields are required" }, { status: 400 });
}
try {
// Check if settings exist
const existingSettings = await prisma.mailSettings.findFirst();
if (existingSettings) {
// Update existing settings
await prisma.mailSettings.update({
where: { id: existingSettings.id },
data: {
host,
port,
secure,
username,
password,
fromName,
fromEmail,
},
});
} else {
// Create new settings
await prisma.mailSettings.create({
data: {
host,
port,
secure,
username,
password,
fromName,
fromEmail,
},
});
}
return json({ success: "Mail settings saved successfully" });
} catch (error) {
return json({ error: "Failed to save mail settings" }, { status: 500 });
}
}
export default function MailSettings() {
const { mailSettings, user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [showPassword, setShowPassword] = useState(false);
return (
<DashboardLayout user={user}>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Mail Settings</h1>
{/* SMTP Configuration Examples */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-2">Common SMTP Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="font-medium text-blue-700">Gmail</h4>
<p>Host: smtp.gmail.com</p>
<p>Port: 587 (TLS) or 465 (SSL)</p>
<p>Note: Use App Password, not regular password</p>
</div>
<div>
<h4 className="font-medium text-blue-700">Outlook/Hotmail</h4>
<p>Host: smtp-mail.outlook.com</p>
<p>Port: 587 (TLS)</p>
<p>Secure: No (uses STARTTLS)</p>
</div>
<div>
<h4 className="font-medium text-blue-700">Yahoo</h4>
<p>Host: smtp.mail.yahoo.com</p>
<p>Port: 587 (TLS) or 465 (SSL)</p>
<p>Note: Enable "Less secure apps"</p>
</div>
<div>
<h4 className="font-medium text-blue-700">Custom SMTP</h4>
<p>Contact your email provider</p>
<p>Port 587 (TLS) is most common</p>
<p>Port 465 (SSL) for secure connections</p>
</div>
</div>
</div>
{actionData?.error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{actionData.success}
</div>
)}
<Form method="post" className="space-y-4">
<div>
<label htmlFor="host" className="block text-sm font-medium text-gray-700">
SMTP Host
</label>
<input
type="text"
id="host"
name="host"
defaultValue={mailSettings?.host || ""}
className="mt-1 p-2 h-9 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="smtp.gmail.com"
required
/>
</div>
<div>
<label htmlFor="port" className="block text-sm font-medium text-gray-700">
SMTP Port
</label>
<input
type="number"
id="port"
name="port"
defaultValue={mailSettings?.port || 587}
className="mt-1 block p-2 h-9 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
name="secure"
defaultChecked={mailSettings?.secure || false}
className="rounded p-2 h-9 border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">Use SSL/TLS</span>
</label>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
type="text"
id="username"
name="username"
defaultValue={mailSettings?.username || ""}
className="mt-1 block p-2 h-9 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
defaultValue={mailSettings?.password || ""}
className="mt-1 block w-full p-2 h-9 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-sm leading-5"
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
</div>
<div>
<label htmlFor="fromName" className="block text-sm font-medium text-gray-700">
From Name
</label>
<input
type="text"
id="fromName"
name="fromName"
defaultValue={mailSettings?.fromName || ""}
className="mt-1 block w-full p-2 h-9 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Your Company Name"
required
/>
</div>
<div>
<label htmlFor="fromEmail" className="block text-sm font-medium text-gray-700">
From Email
</label>
<input
type="email"
id="fromEmail"
name="fromEmail"
defaultValue={mailSettings?.fromEmail || ""}
className="mt-1 block p-2 h-9 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="noreply@yourcompany.com"
required
/>
</div>
<div className="flex space-x-4">
<button
type="submit"
className="flex-1 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save Mail Settings
</button>
<button
type="submit"
name="_action"
value="test"
className="flex-1 flex justify-center py-2 px-4 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"
>
Test Connection
</button>
</div>
</Form>
</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1,281 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import FormModal from "~/components/FormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Reclamation Locations Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 2);
const reclamationLocations = await prisma.reclamationLocation.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { reports: true }
}
}
});
return json({ user, reclamationLocations });
};
export const action = async ({ request }: ActionFunctionArgs) => {
await requireAuthLevel(request, 2);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
const name = formData.get("name");
if (intent === "create") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
try {
await prisma.reclamationLocation.create({
data: { name }
});
return json({ success: "Reclamation location created successfully!" });
} catch (error) {
return json({ errors: { form: "Reclamation location name already exists" } }, { status: 400 });
}
}
if (intent === "update") {
if (typeof name !== "string" || name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (typeof id !== "string") {
return json({ errors: { form: "Invalid reclamation location ID" } }, { status: 400 });
}
try {
await prisma.reclamationLocation.update({
where: { id: parseInt(id) },
data: { name }
});
return json({ success: "Reclamation location updated successfully!" });
} catch (error) {
return json({ errors: { form: "Reclamation location name already exists" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid reclamation location ID" } }, { status: 400 });
}
try {
await prisma.reclamationLocation.delete({
where: { id: parseInt(id) }
});
return json({ success: "Reclamation location deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Cannot delete reclamation location with existing reports" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function ReclamationLocations() {
const { user, reclamationLocations } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [editingLocation, setEditingLocation] = useState<{ id: number; name: string } | null>(null);
const [showModal, setShowModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingLocation !== null;
// Handle success/error messages
useEffect(() => {
if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingLocation(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData]);
const handleEdit = (location: { id: number; name: string }) => {
setEditingLocation(location);
setShowModal(true);
};
const handleAdd = () => {
setEditingLocation(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingLocation(null);
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reclamation Locations Management</h1>
<p className="mt-1 text-sm text-gray-600">Manage shoreline reclamation locations</p>
</div>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add New Location
</button>
</div>
{/* Locations Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Location Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reports Count
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reclamationLocations.map((location) => (
<tr key={location.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{location.name}</div>
{/* <div className="text-sm text-gray-500">Location #{location.id}</div> */}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{location._count.reports} reports
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEdit(location)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={location.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this reclamation location?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{reclamationLocations.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No reclamation locations</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating your first reclamation location.</p>
<div className="mt-6">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Location
</button>
</div>
</div>
)}
</div>
{/* Form Modal */}
<FormModal
isOpen={showModal}
onClose={handleCloseModal}
title={isEditing ? "Edit Reclamation Location" : "Add New Reclamation Location"}
isSubmitting={isSubmitting}
submitText={isEditing ? "Update Location" : "Create Location"}
>
<Form method="post" id="modal-form" className="space-y-4">
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
{isEditing && <input type="hidden" name="id" value={editingLocation?.id} />}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Location Name
</label>
<input
type="text"
name="name"
id="name"
required
defaultValue={editingLocation?.name || ""}
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter location name (e.g., Eastern Shoreline)"
/>
{actionData?.errors?.name && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
)}
</div>
</Form>
</FormModal>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

676
app/routes/reports.tsx Normal file
View File

@ -0,0 +1,676 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation, Link, useSearchParams } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import ReportViewModal from "~/components/ReportViewModal";
import ReportFormModal from "~/components/ReportFormModal";
import Toast from "~/components/Toast";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Reports Management - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1); // All employees can access reports
// Get all reports with related data
let reports = await prisma.report.findMany({
// where: { employeeId: user.id },
orderBy: { createdDate: 'desc' },
include: {
employee: { select: { name: true } },
area: { select: { name: true } },
dredgerLocation: { select: { name: true, class: true } },
reclamationLocation: { select: { name: true } }
}
});
if (user.authLevel === 1){
// filter report by user id
reports = reports.filter((report: any) => report.employeeId === user.id);
}
// if (user.authLevel === 1) {
// reports = await prisma.report.findMany({
// where: { employeeId: user.id },
// orderBy: { createdDate: 'desc' },
// include: {
// employee: { select: { name: true } },
// area: { select: { name: true } },
// dredgerLocation: { select: { name: true, class: true } },
// reclamationLocation: { select: { name: true } }
// }
// });
// }
// Get dropdown data for edit form only
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([
prisma.area.findMany({ orderBy: { name: 'asc' } }),
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] })
]);
return json({
user,
reports,
areas,
dredgerLocations,
reclamationLocations,
foremen,
equipment
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireAuthLevel(request, 1);
const formData = await request.formData();
const intent = formData.get("intent");
const id = formData.get("id");
if (intent === "update") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid report ID" } }, { status: 400 });
}
// Check if user owns this report or has admin privileges
const existingReport = await prisma.report.findUnique({
where: { id: parseInt(id) },
select: { employeeId: true, createdDate: true }
});
if (!existingReport) {
return json({ errors: { form: "Report not found" } }, { status: 404 });
}
if (user.authLevel < 2) {
// Regular users can only edit their own reports
if (existingReport.employeeId !== user.id) {
return json({ errors: { form: "You can only edit your own reports" } }, { status: 403 });
}
// Regular users can only edit their latest report
const latestUserReport = await prisma.report.findFirst({
where: { employeeId: user.id },
orderBy: { createdDate: 'desc' },
select: { id: true }
});
if (!latestUserReport || latestUserReport.id !== parseInt(id)) {
return json({ errors: { form: "You can only edit your latest report" } }, { status: 403 });
}
}
const shift = formData.get("shift");
const areaId = formData.get("areaId");
const dredgerLocationId = formData.get("dredgerLocationId");
const dredgerLineLength = formData.get("dredgerLineLength");
const reclamationLocationId = formData.get("reclamationLocationId");
const shoreConnection = formData.get("shoreConnection");
const notes = formData.get("notes");
// Complex JSON fields
const reclamationHeightBase = formData.get("reclamationHeightBase");
const reclamationHeightExtra = formData.get("reclamationHeightExtra");
const pipelineMain = formData.get("pipelineMain");
const pipelineExt1 = formData.get("pipelineExt1");
const pipelineReserve = formData.get("pipelineReserve");
const pipelineExt2 = formData.get("pipelineExt2");
const statsDozers = formData.get("statsDozers");
const statsExc = formData.get("statsExc");
const statsLoaders = formData.get("statsLoaders");
const statsForeman = formData.get("statsForeman");
const statsLaborer = formData.get("statsLaborer");
const timeSheetData = formData.get("timeSheetData");
const stoppagesData = formData.get("stoppagesData");
// Validation (same as create)
if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
}
if (typeof areaId !== "string" || !areaId) {
return json({ errors: { areaId: "Area is required" } }, { status: 400 });
}
if (typeof dredgerLocationId !== "string" || !dredgerLocationId) {
return json({ errors: { dredgerLocationId: "Dredger location is required" } }, { status: 400 });
}
if (typeof dredgerLineLength !== "string" || isNaN(parseInt(dredgerLineLength))) {
return json({ errors: { dredgerLineLength: "Valid dredger line length is required" } }, { status: 400 });
}
if (typeof reclamationLocationId !== "string" || !reclamationLocationId) {
return json({ errors: { reclamationLocationId: "Reclamation location is required" } }, { status: 400 });
}
if (typeof shoreConnection !== "string" || isNaN(parseInt(shoreConnection))) {
return json({ errors: { shoreConnection: "Valid shore connection is required" } }, { status: 400 });
}
try {
// Parse JSON arrays
let timeSheet = [];
let stoppages = [];
if (timeSheetData && typeof timeSheetData === "string") {
try {
timeSheet = JSON.parse(timeSheetData);
} catch (e) {
timeSheet = [];
}
}
if (stoppagesData && typeof stoppagesData === "string") {
try {
stoppages = JSON.parse(stoppagesData);
} catch (e) {
stoppages = [];
}
}
await prisma.report.update({
where: { id: parseInt(id) },
data: {
shift,
areaId: parseInt(areaId),
dredgerLocationId: parseInt(dredgerLocationId),
dredgerLineLength: parseInt(dredgerLineLength),
reclamationLocationId: parseInt(reclamationLocationId),
shoreConnection: parseInt(shoreConnection),
reclamationHeight: {
base: parseInt(reclamationHeightBase as string) || 0,
extra: parseInt(reclamationHeightExtra as string) || 0
},
pipelineLength: {
main: parseInt(pipelineMain as string) || 0,
ext1: parseInt(pipelineExt1 as string) || 0,
reserve: parseInt(pipelineReserve as string) || 0,
ext2: parseInt(pipelineExt2 as string) || 0
},
stats: {
Dozers: parseInt(statsDozers as string) || 0,
Exc: parseInt(statsExc as string) || 0,
Loaders: parseInt(statsLoaders as string) || 0,
Foreman: statsForeman as string || "",
Laborer: parseInt(statsLaborer as string) || 0
},
timeSheet,
stoppages,
notes: notes || null
}
});
return json({ success: "Report updated successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to update report" } }, { status: 400 });
}
}
if (intent === "delete") {
if (typeof id !== "string") {
return json({ errors: { form: "Invalid report ID" } }, { status: 400 });
}
// Check if user owns this report or has admin privileges
const existingReport = await prisma.report.findUnique({
where: { id: parseInt(id) },
select: { employeeId: true, createdDate: true }
});
if (!existingReport) {
return json({ errors: { form: "Report not found" } }, { status: 404 });
}
if (user.authLevel < 2) {
// Regular users can only delete their own reports
if (existingReport.employeeId !== user.id) {
return json({ errors: { form: "You can only delete your own reports" } }, { status: 403 });
}
// Regular users can only delete their latest report
const latestUserReport = await prisma.report.findFirst({
where: { employeeId: user.id },
orderBy: { createdDate: 'desc' },
select: { id: true }
});
if (!latestUserReport || latestUserReport.id !== parseInt(id)) {
return json({ errors: { form: "You can only delete your latest report" } }, { status: 403 });
}
}
try {
await prisma.report.delete({
where: { id: parseInt(id) }
});
return json({ success: "Report deleted successfully!" });
} catch (error) {
return json({ errors: { form: "Failed to delete report" } }, { status: 400 });
}
}
return json({ errors: { form: "Invalid action" } }, { status: 400 });
};
export default function Reports() {
const { user, reports, areas, dredgerLocations, reclamationLocations, foremen, equipment } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const [editingReport, setEditingReport] = useState<any>(null);
const [viewingReport, setViewingReport] = useState<any>(null);
const [showModal, setShowModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
// Dynamic arrays state for editing only
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
id: string,
machine: string,
from1: string,
to1: string,
from2: string,
to2: string,
total: string,
reason: string
}>>([]);
const [stoppageEntries, setStoppageEntries] = useState<Array<{
id: string,
from: string,
to: string,
total: string,
reason: string,
responsible: string,
note: string
}>>([]);
const isSubmitting = navigation.state === "submitting";
const isEditing = editingReport !== null;
// Handle success/error messages from URL params and action data
useEffect(() => {
const successMessage = searchParams.get("success");
const errorMessage = searchParams.get("error");
if (successMessage) {
setToast({ message: successMessage, type: "success" });
// Clear the URL parameter
window.history.replaceState({}, '', '/reports');
} else if (errorMessage) {
setToast({ message: errorMessage, type: "error" });
// Clear the URL parameter
window.history.replaceState({}, '', '/reports');
} else if (actionData?.success) {
setToast({ message: actionData.success, type: "success" });
setShowModal(false);
setEditingReport(null);
} else if (actionData?.errors?.form) {
setToast({ message: actionData.errors.form, type: "error" });
}
}, [actionData, searchParams]);
const handleView = (report: any) => {
setViewingReport(report);
setShowViewModal(true);
};
const handleEdit = (report: any) => {
setEditingReport(report);
// Load existing timesheet and stoppages data
setTimeSheetEntries(Array.isArray(report.timeSheet) ? report.timeSheet : []);
setStoppageEntries(Array.isArray(report.stoppages) ? report.stoppages : []);
setShowModal(true);
};
// Remove handleAdd since we're using a separate page
const handleCloseModal = () => {
setShowModal(false);
setEditingReport(null);
setTimeSheetEntries([]);
setStoppageEntries([]);
};
const handleCloseViewModal = () => {
setShowViewModal(false);
setViewingReport(null);
};
// Helper function to calculate time difference in hours:minutes format
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => {
if (!from1 || !to1) return "00:00";
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const formatTime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
};
let totalMinutes = 0;
// First period
if (from1 && to1) {
const start1 = parseTime(from1);
const end1 = parseTime(to1);
totalMinutes += end1 - start1;
}
// Second period
if (from2 && to2) {
const start2 = parseTime(from2);
const end2 = parseTime(to2);
totalMinutes += end2 - start2;
}
return formatTime(Math.max(0, totalMinutes));
};
const calculateStoppageTime = (from: string, to: string) => {
if (!from || !to) return "00:00";
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const formatTime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
};
const startMinutes = parseTime(from);
const endMinutes = parseTime(to);
const totalMinutes = Math.max(0, endMinutes - startMinutes);
return formatTime(totalMinutes);
};
// TimeSheet management functions
const addTimeSheetEntry = () => {
const newEntry = {
id: Date.now().toString(),
machine: '',
from1: '',
to1: '',
from2: '',
to2: '',
total: '00:00',
reason: ''
};
setTimeSheetEntries([...timeSheetEntries, newEntry]);
};
const removeTimeSheetEntry = (id: string) => {
setTimeSheetEntries(timeSheetEntries.filter(entry => entry.id !== id));
};
const updateTimeSheetEntry = (id: string, field: string, value: string) => {
setTimeSheetEntries(timeSheetEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
// Auto-calculate total when time fields change
if (['from1', 'to1', 'from2', 'to2'].includes(field)) {
updatedEntry.total = calculateTimeDifference(
updatedEntry.from1,
updatedEntry.to1,
updatedEntry.from2,
updatedEntry.to2
);
}
return updatedEntry;
}
return entry;
}));
};
// Stoppage management functions
const addStoppageEntry = () => {
const newEntry = {
id: Date.now().toString(),
from: '',
to: '',
total: '00:00',
reason: '',
responsible: '',
note: ''
};
setStoppageEntries([...stoppageEntries, newEntry]);
};
const removeStoppageEntry = (id: string) => {
setStoppageEntries(stoppageEntries.filter(entry => entry.id !== id));
};
const updateStoppageEntry = (id: string, field: string, value: string) => {
setStoppageEntries(stoppageEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
// Auto-calculate total when time fields change
if (['from', 'to'].includes(field)) {
updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to);
}
return updatedEntry;
}
return entry;
}));
};
const getShiftBadge = (shift: string) => {
return shift === "day"
? "bg-yellow-100 text-yellow-800"
: "bg-blue-100 text-blue-800";
};
const canEditReport = (report: any) => {
// Admin users (auth level >= 2) can edit any report
if (user.authLevel >= 2) {
return true;
}
// Regular users (auth level 1) can only edit their own latest report
if (report.employeeId === user.id) {
// Find the latest report for this user
const userReports = reports.filter(r => r.employeeId === user.id);
const latestReport = userReports.reduce((latest, current) =>
new Date(current.createdDate) > new Date(latest.createdDate) ? current : latest
);
return report.id === latestReport.id;
}
return false;
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reports Management</h1>
<p className="mt-1 text-sm text-gray-600">Create and manage operational reports</p>
</div>
<Link
to="/reports/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Create New Report
</Link>
</div>
{/* Reports Table */}
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Report Details
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Shift & Area
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Locations
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reports.map((report) => (
<tr key={report.id} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<svg className="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">Report #{report.id}</div>
<div className="text-sm text-gray-500">by {report.employee.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col space-y-1">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getShiftBadge(report.shift)}`}>
{report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift
</span>
<span className="text-sm text-gray-900">{report.area.name}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
<div> {report.area.name} Dredger</div>
<div className="text-gray-500"> {report.dredgerLocation.name} - {report.reclamationLocation.name}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{/* {new Date(report.createdDate).toLocaleDateString()} */}
{new Date(report.createdDate).toLocaleDateString('en-GB')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleView(report)}
className="text-blue-600 hover:text-blue-900 transition-colors duration-150"
>
View
</button>
{canEditReport(report) && (
<>
<button
onClick={() => handleEdit(report)}
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
>
Edit
</button>
<Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={report.id} />
<button
type="submit"
onClick={(e) => {
if (!confirm("Are you sure you want to delete this report?")) {
e.preventDefault();
}
}}
className="text-red-600 hover:text-red-900 transition-colors duration-150"
>
Delete
</button>
</Form>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{reports.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No reports</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating your first report.</p>
<div className="mt-6">
<Link
to="/reports/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Create Report
</Link>
</div>
</div>
)}
</div>
{/* Edit Form Modal - Only for editing existing reports */}
{isEditing && (
<ReportFormModal
isOpen={showModal}
onClose={handleCloseModal}
isEditing={isEditing}
isSubmitting={isSubmitting}
editingReport={editingReport}
actionData={actionData}
areas={areas}
dredgerLocations={dredgerLocations}
reclamationLocations={reclamationLocations}
foremen={foremen}
equipment={equipment}
timeSheetEntries={timeSheetEntries}
stoppageEntries={stoppageEntries}
addTimeSheetEntry={addTimeSheetEntry}
removeTimeSheetEntry={removeTimeSheetEntry}
updateTimeSheetEntry={updateTimeSheetEntry}
addStoppageEntry={addStoppageEntry}
removeStoppageEntry={removeStoppageEntry}
updateStoppageEntry={updateStoppageEntry}
/>
)}
{/* View Modal */}
<ReportViewModal
isOpen={showViewModal}
onClose={handleCloseViewModal}
report={viewingReport}
/>
{/* Toast Notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
</DashboardLayout>
);
}

736
app/routes/reports_.new.tsx Normal file
View File

@ -0,0 +1,736 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation, Link } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import { PrismaClient } from "@prisma/client";
import { useState, useEffect } from "react";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "New Report - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuthLevel(request, 1); // All employees can create reports
// Get dropdown data for form
const [areas, dredgerLocations, reclamationLocations, foremen, equipment] = await Promise.all([
prisma.area.findMany({ orderBy: { name: 'asc' } }),
prisma.dredgerLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.reclamationLocation.findMany({ orderBy: { name: 'asc' } }),
prisma.foreman.findMany({ orderBy: { name: 'asc' } }),
prisma.equipment.findMany({ orderBy: [{ category: 'asc' }, { model: 'asc' }, { number: 'asc' }] })
]);
return json({
user,
areas,
dredgerLocations,
reclamationLocations,
foremen,
equipment
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireAuthLevel(request, 1);
const formData = await request.formData();
// Debug logging
console.log("Form data received:", Object.fromEntries(formData.entries()));
const shift = formData.get("shift");
const areaId = formData.get("areaId");
const dredgerLocationId = formData.get("dredgerLocationId");
const dredgerLineLength = formData.get("dredgerLineLength");
const reclamationLocationId = formData.get("reclamationLocationId");
const shoreConnection = formData.get("shoreConnection");
const notes = formData.get("notes");
// Complex JSON fields
const reclamationHeightBase = formData.get("reclamationHeightBase");
const reclamationHeightExtra = formData.get("reclamationHeightExtra");
const pipelineMain = formData.get("pipelineMain");
const pipelineExt1 = formData.get("pipelineExt1");
const pipelineReserve = formData.get("pipelineReserve");
const pipelineExt2 = formData.get("pipelineExt2");
const statsDozers = formData.get("statsDozers");
const statsExc = formData.get("statsExc");
const statsLoaders = formData.get("statsLoaders");
const statsForeman = formData.get("statsForeman");
const statsLaborer = formData.get("statsLaborer");
const timeSheetData = formData.get("timeSheetData");
const stoppagesData = formData.get("stoppagesData");
// Validation
// console.log("Validating fields:", { shift, areaId, dredgerLocationId, dredgerLineLength, reclamationLocationId, shoreConnection });
if (typeof shift !== "string" || !["day", "night"].includes(shift)) {
console.log("Shift validation failed:", shift);
return json({ errors: { shift: "Valid shift is required" } }, { status: 400 });
}
if (typeof areaId !== "string" || !areaId) {
return json({ errors: { areaId: "Area is required" } }, { status: 400 });
}
if (typeof dredgerLocationId !== "string" || !dredgerLocationId) {
return json({ errors: { dredgerLocationId: "Dredger location is required" } }, { status: 400 });
}
if (typeof dredgerLineLength !== "string" || isNaN(parseInt(dredgerLineLength))) {
return json({ errors: { dredgerLineLength: "Valid dredger line length is required" } }, { status: 400 });
}
if (typeof reclamationLocationId !== "string" || !reclamationLocationId) {
return json({ errors: { reclamationLocationId: "Reclamation location is required" } }, { status: 400 });
}
if (typeof shoreConnection !== "string" || isNaN(parseInt(shoreConnection))) {
return json({ errors: { shoreConnection: "Valid shore connection is required" } }, { status: 400 });
}
try {
// Parse JSON arrays
let timeSheet = [];
let stoppages = [];
if (timeSheetData && typeof timeSheetData === "string") {
try {
timeSheet = JSON.parse(timeSheetData);
} catch (e) {
timeSheet = [];
}
}
if (stoppagesData && typeof stoppagesData === "string") {
try {
stoppages = JSON.parse(stoppagesData);
} catch (e) {
stoppages = [];
}
}
await prisma.report.create({
data: {
employeeId: user.id,
shift,
areaId: parseInt(areaId),
dredgerLocationId: parseInt(dredgerLocationId),
dredgerLineLength: parseInt(dredgerLineLength),
reclamationLocationId: parseInt(reclamationLocationId),
shoreConnection: parseInt(shoreConnection),
reclamationHeight: {
base: parseInt(reclamationHeightBase as string) || 0,
extra: parseInt(reclamationHeightExtra as string) || 0
},
pipelineLength: {
main: parseInt(pipelineMain as string) || 0,
ext1: parseInt(pipelineExt1 as string) || 0,
reserve: parseInt(pipelineReserve as string) || 0,
ext2: parseInt(pipelineExt2 as string) || 0
},
stats: {
Dozers: parseInt(statsDozers as string) || 0,
Exc: parseInt(statsExc as string) || 0,
Loaders: parseInt(statsLoaders as string) || 0,
Foreman: statsForeman as string || "",
Laborer: parseInt(statsLaborer as string) || 0
},
timeSheet,
stoppages,
notes: notes || null
}
});
// Redirect to reports page with success message
return redirect("/reports?success=Report created successfully!");
} catch (error) {
return json({ errors: { form: "Failed to create report. Please try again." } }, { status: 400 });
}
};
export default function NewReport() {
const { user, areas, dredgerLocations, reclamationLocations, foremen, equipment } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
// Form state to preserve values across steps
const [formData, setFormData] = useState({
shift: '',
areaId: '',
dredgerLocationId: '',
dredgerLineLength: '',
reclamationLocationId: '',
shoreConnection: '',
reclamationHeightBase: '0',
reclamationHeightExtra: '0',
pipelineMain: '0',
pipelineExt1: '0',
pipelineReserve: '0',
pipelineExt2: '0',
statsDozers: '0',
statsExc: '0',
statsLoaders: '0',
statsForeman: '',
statsLaborer: '0',
notes: ''
});
// Dynamic arrays state
const [timeSheetEntries, setTimeSheetEntries] = useState<Array<{
id: string,
machine: string,
from1: string,
to1: string,
from2: string,
to2: string,
total: string,
reason: string
}>>([]);
const [stoppageEntries, setStoppageEntries] = useState<Array<{
id: string,
from: string,
to: string,
total: string,
reason: string,
responsible: string,
note: string
}>>([]);
const [currentStep, setCurrentStep] = useState(1);
const totalSteps = 4;
const isSubmitting = navigation.state === "submitting";
// Function to update form data
const updateFormData = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Handle form submission - only allow on final step
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
// console.log("Form submit triggered, current step:", currentStep);
if (currentStep !== totalSteps) {
console.log("Preventing form submission - not on final step");
event.preventDefault();
event.stopPropagation();
return false;
}
// console.log("Allowing form submission");
// console.log("Form being submitted with data:", formData);
// console.log("Time sheet entries:", timeSheetEntries);
// console.log("Stoppage entries:", stoppageEntries);
};
// Helper functions for time calculations
const calculateTimeDifference = (from1: string, to1: string, from2: string, to2: string) => {
if (!from1 || !to1) return "00:00";
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const formatTime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
};
let totalMinutes = 0;
if (from1 && to1) {
const start1 = parseTime(from1);
const end1 = parseTime(to1);
totalMinutes += end1 - start1;
}
if (from2 && to2) {
const start2 = parseTime(from2);
const end2 = parseTime(to2);
totalMinutes += end2 - start2;
}
return formatTime(Math.max(0, totalMinutes));
};
const calculateStoppageTime = (from: string, to: string) => {
if (!from || !to) return "00:00";
const parseTime = (timeStr: string) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const formatTime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
};
const startMinutes = parseTime(from);
const endMinutes = parseTime(to);
const totalMinutes = Math.max(0, endMinutes - startMinutes);
return formatTime(totalMinutes);
};
// Time Sheet management
const addTimeSheetEntry = () => {
const newEntry = {
id: Date.now().toString(),
machine: '',
from1: '',
to1: '',
from2: '',
to2: '',
total: '00:00',
reason: ''
};
setTimeSheetEntries([...timeSheetEntries, newEntry]);
};
const removeTimeSheetEntry = (id: string) => {
setTimeSheetEntries(timeSheetEntries.filter(entry => entry.id !== id));
};
const updateTimeSheetEntry = (id: string, field: string, value: string) => {
setTimeSheetEntries(timeSheetEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
if (['from1', 'to1', 'from2', 'to2'].includes(field)) {
updatedEntry.total = calculateTimeDifference(
updatedEntry.from1,
updatedEntry.to1,
updatedEntry.from2,
updatedEntry.to2
);
}
return updatedEntry;
}
return entry;
}));
};
// Stoppage management
const addStoppageEntry = () => {
const newEntry = {
id: Date.now().toString(),
from: '',
to: '',
total: '00:00',
reason: '',
responsible: '',
note: ''
};
setStoppageEntries([...stoppageEntries, newEntry]);
};
const removeStoppageEntry = (id: string) => {
setStoppageEntries(stoppageEntries.filter(entry => entry.id !== id));
};
const updateStoppageEntry = (id: string, field: string, value: string) => {
setStoppageEntries(stoppageEntries.map(entry => {
if (entry.id === id) {
const updatedEntry = { ...entry, [field]: value };
if (['from', 'to'].includes(field)) {
updatedEntry.total = calculateStoppageTime(updatedEntry.from, updatedEntry.to);
}
return updatedEntry;
}
return entry;
}));
};
const nextStep = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
// console.log("Next step clicked, current step:", currentStep);
if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1);
// console.log("Moving to step:", currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const getStepTitle = (step: number) => {
switch (step) {
case 1: return "Basic Information";
case 2: return "Location & Pipeline Details";
case 3: return "Equipment & Time Sheet";
case 4: return "Stoppages & Notes";
default: return "";
}
};
return (
<DashboardLayout user={user}>
<div className="max-w-full mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Create New Report</h1>
<p className="mt-2 text-gray-600">Fill out the operational report details step by step</p>
</div>
<Link
to="/reports"
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<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>
Back to Reports
</Link>
</div>
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{[1, 2, 3, 4].map((step) => (
<div key={step} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-2 ${
step <= currentStep
? 'bg-indigo-600 border-indigo-600 text-white'
: 'border-gray-300 text-gray-500'
}`}>
{step < currentStep ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<span className="text-sm font-medium">{step}</span>
)}
</div>
{step < totalSteps && (
<div className={`flex-1 h-1 mx-4 ${
step < currentStep ? 'bg-indigo-600' : 'bg-gray-300'
}`} />
)}
</div>
))}
</div>
<div className="mt-4 text-center">
<h2 className="text-xl font-semibold text-gray-900">{getStepTitle(currentStep)}</h2>
<p className="text-sm text-gray-500">Step {currentStep} of {totalSteps}</p>
</div>
</div>
{/* Form */}
<Form method="post" onSubmit={handleSubmit} className="bg-white shadow-lg rounded-lg overflow-hidden">
<div className="p-6">
{/* Step 1: Basic Information */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="shift" className="block text-sm font-medium text-gray-700 mb-2">
Shift <span className="text-red-500">*</span>
</label>
<select
id="shift"
name="shift"
required
value={formData.shift}
onChange={(e) => updateFormData('shift', 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 shift</option>
<option value="day">Day Shift</option>
<option value="night">Night Shift</option>
</select>
{actionData?.errors?.shift && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.shift}</p>
)}
</div>
<div>
<label htmlFor="areaId" className="block text-sm font-medium text-gray-700 mb-2">
Area <span className="text-red-500">*</span>
</label>
<select
id="areaId"
name="areaId"
required
value={formData.areaId}
onChange={(e) => updateFormData('areaId', 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 area</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
{actionData?.errors?.areaId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.areaId}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="dredgerLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Dredger Location <span className="text-red-500">*</span>
</label>
<select
id="dredgerLocationId"
name="dredgerLocationId"
required
value={formData.dredgerLocationId}
onChange={(e) => updateFormData('dredgerLocationId', 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 dredger location</option>
{dredgerLocations.map((location) => (
<option key={location.id} value={location.id}>
{location.name} ({location.class.toUpperCase()})
</option>
))}
</select>
{actionData?.errors?.dredgerLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLocationId}</p>
)}
</div>
<div>
<label htmlFor="dredgerLineLength" className="block text-sm font-medium text-gray-700 mb-2">
Dredger Line Length (m) <span className="text-red-500">*</span>
</label>
<input
type="number"
id="dredgerLineLength"
name="dredgerLineLength"
required
min="0"
value={formData.dredgerLineLength}
onChange={(e) => updateFormData('dredgerLineLength', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Enter length in meters"
/>
{actionData?.errors?.dredgerLineLength && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.dredgerLineLength}</p>
)}
</div>
</div>
</div>
)}
{/* Step 2: Location & Pipeline Details */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="reclamationLocationId" className="block text-sm font-medium text-gray-700 mb-2">
Reclamation Location <span className="text-red-500">*</span>
</label>
<select id="reclamationLocationId" name="reclamationLocationId" required value={formData.reclamationLocationId} onChange={(e) => updateFormData('reclamationLocationId', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Select reclamation location</option>
{reclamationLocations.map((location) => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
{actionData?.errors?.reclamationLocationId && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.reclamationLocationId}</p>
)}
</div>
<div>
<label htmlFor="shoreConnection" className="block text-sm font-medium text-gray-700 mb-2">Shore Connection <span className="text-red-500">*</span></label>
<input type="number" id="shoreConnection" name="shoreConnection" required min="0" value={formData.shoreConnection} onChange={(e) => updateFormData('shoreConnection', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter connection length" />
{actionData?.errors?.shoreConnection && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.shoreConnection}</p>
)}
</div>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Reclamation Height</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label htmlFor="reclamationHeightBase" className="block text-sm font-medium text-gray-700 mb-2">Base Height (m)</label><input type="number" id="reclamationHeightBase" name="reclamationHeightBase" min="0" value={formData.reclamationHeightBase} onChange={(e) => updateFormData('reclamationHeightBase', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="reclamationHeightExtra" className="block text-sm font-medium text-gray-700 mb-2">Extra Height (m)</label><input type="number" id="reclamationHeightExtra" name="reclamationHeightExtra" min="0" value={formData.reclamationHeightExtra} onChange={(e) => updateFormData('reclamationHeightExtra', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
</div>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Pipeline Length</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div><label htmlFor="pipelineMain" className="block text-sm font-medium text-gray-700 mb-2">Main</label><input type="number" id="pipelineMain" name="pipelineMain" min="0" value={formData.pipelineMain} onChange={(e) => updateFormData('pipelineMain', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="pipelineExt1" className="block text-sm font-medium text-gray-700 mb-2">Extension 1</label><input type="number" id="pipelineExt1" name="pipelineExt1" min="0" value={formData.pipelineExt1} onChange={(e) => updateFormData('pipelineExt1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="pipelineReserve" className="block text-sm font-medium text-gray-700 mb-2">Reserve</label><input type="number" id="pipelineReserve" name="pipelineReserve" min="0" value={formData.pipelineReserve} onChange={(e) => updateFormData('pipelineReserve', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="pipelineExt2" className="block text-sm font-medium text-gray-700 mb-2">Extension 2</label><input type="number" id="pipelineExt2" name="pipelineExt2" min="0" value={formData.pipelineExt2} onChange={(e) => updateFormData('pipelineExt2', 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>
)}
{/* Step 3: Equipment & Time Sheet */}
{currentStep === 3 && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Equipment Statistics</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div><label htmlFor="statsDozers" className="block text-sm font-medium text-gray-700 mb-2">Dozers</label><input type="number" id="statsDozers" name="statsDozers" min="0" value={formData.statsDozers} onChange={(e) => updateFormData('statsDozers', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="statsExc" className="block text-sm font-medium text-gray-700 mb-2">Excavators</label><input type="number" id="statsExc" name="statsExc" min="0" value={formData.statsExc} onChange={(e) => updateFormData('statsExc', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="statsLoaders" className="block text-sm font-medium text-gray-700 mb-2">Loaders</label><input type="number" id="statsLoaders" name="statsLoaders" min="0" value={formData.statsLoaders} onChange={(e) => updateFormData('statsLoaders', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label htmlFor="statsForeman" className="block text-sm font-medium text-gray-700 mb-2">Foreman</label><select id="statsForeman" name="statsForeman" value={formData.statsForeman} onChange={(e) => updateFormData('statsForeman', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select foreman</option>{foremen.map((foreman) => (<option key={foreman.id} value={foreman.name}>{foreman.name}</option>))}</select></div>
<div><label htmlFor="statsLaborer" className="block text-sm font-medium text-gray-700 mb-2">Laborers</label><input type="number" id="statsLaborer" name="statsLaborer" min="0" value={formData.statsLaborer} onChange={(e) => updateFormData('statsLaborer', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Time Sheet</h3>
<button type="button" onClick={addTimeSheetEntry} className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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">
<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 6v6m0 0v6m0-6h6m-6 0H6" /></svg>Add Entry
</button>
</div>
{timeSheetEntries.length > 0 ? (
<div className="space-y-4">
{timeSheetEntries.map((entry) => (
<div key={entry.id} className="bg-gray-50 p-4 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-7 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">Equipment</label><select value={entry.machine} onChange={(e) => updateTimeSheetEntry(entry.id, 'machine', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="">Select Equipment</option>{equipment.map((item) => (<option key={item.id} value={`${item.model} (${item.number})`}>{item.category} - {item.model} ({item.number})</option>))}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">From</label><input type="time" value={entry.from1} onChange={(e) => updateTimeSheetEntry(entry.id, 'from1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">To</label><input type="time" value={entry.to1} onChange={(e) => updateTimeSheetEntry(entry.id, 'to1', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">From 2</label><input type="time" value={entry.from2} onChange={(e) => updateTimeSheetEntry(entry.id, 'from2', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">To 2</label><input type="time" value={entry.to2} onChange={(e) => updateTimeSheetEntry(entry.id, 'to2', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Total</label><input type="text" value={entry.total} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100" /></div>
<div className="flex items-end"><button type="button" onClick={() => removeTimeSheetEntry(entry.id)} className="w-full px-3 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Remove</button></div>
</div>
<div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-1">Reason</label><input type="text" value={entry.reason} onChange={(e) => updateTimeSheetEntry(entry.id, 'reason', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Reason for downtime (if any)" /></div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500"><p className="mt-2">No time sheet entries yet. Click "Add Entry" to get started.</p></div>
)}
</div>
</div>
)}
{/* Step 4: Stoppages & Notes */}
{currentStep === 4 && (
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Dredger Stoppages</h3>
<button type="button" onClick={addStoppageEntry} className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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">
<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 6v6m0 0v6m0-6h6m-6 0H6" /></svg>Add Stoppage
</button>
</div>
{stoppageEntries.length > 0 ? (
<div className="space-y-4">
{stoppageEntries.map((entry) => (
<div key={entry.id} className="bg-gray-50 p-4 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">From</label><input type="time" value={entry.from} onChange={(e) => updateStoppageEntry(entry.id, 'from', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">To</label><input type="time" value={entry.to} onChange={(e) => updateStoppageEntry(entry.id, 'to', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Total</label><input type="text" value={entry.total} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Reason</label><input type="text" value={entry.reason} onChange={(e) => updateStoppageEntry(entry.id, 'reason', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Stoppage reason" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Responsible</label><input type="text" value={entry.responsible} onChange={(e) => updateStoppageEntry(entry.id, 'responsible', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Responsible party" /></div>
<div className="flex items-end"><button type="button" onClick={() => removeStoppageEntry(entry.id)} className="w-full px-3 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Remove</button></div>
</div>
<div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-1">Notes</label><input type="text" value={entry.note} onChange={(e) => updateStoppageEntry(entry.id, 'note', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Additional notes" /></div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500"><p className="mt-2">No stoppages recorded. Click "Add Stoppage" if there were any.</p></div>
)}
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-2">Additional Notes & Comments</label>
<textarea id="notes" name="notes" rows={4} value={formData.notes} onChange={(e) => updateFormData('notes', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter any additional notes or comments about the operation..." />
</div>
</div>
)}
{/* Error Message */}
{actionData?.errors?.form && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<svg className="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="ml-3">
<p className="text-sm text-red-800">{actionData.errors.form}</p>
</div>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-between">
<button type="button" onClick={prevStep} disabled={currentStep === 1} className={`inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium ${currentStep === 1 ? 'text-gray-400 bg-gray-100 cursor-not-allowed' : 'text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'}`}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>Previous
</button>
<div className="flex space-x-3">
{currentStep < totalSteps ? (
<button type="button" onClick={(e) => nextStep(e)} className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Next<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button>
) : (
<button type="submit" disabled={isSubmitting} className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed">
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>Creating Report...
</>
) : (
<>
<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="M5 13l4 4L19 7" /></svg>Create Report
</>
)}
</button>
)}
</div>
</div>
{/* Hidden inputs for dynamic data */}
<input type="hidden" name="timeSheetData" value={JSON.stringify(timeSheetEntries)} />
<input type="hidden" name="stoppagesData" value={JSON.stringify(stoppageEntries)} />
{/* Hidden inputs for form data from all steps */}
{currentStep !== 1 && (
<>
<input type="hidden" name="shift" value={formData.shift} />
<input type="hidden" name="areaId" value={formData.areaId} />
<input type="hidden" name="dredgerLocationId" value={formData.dredgerLocationId} />
<input type="hidden" name="dredgerLineLength" value={formData.dredgerLineLength} />
</>
)}
{currentStep !== 2 && (
<>
<input type="hidden" name="reclamationLocationId" value={formData.reclamationLocationId} />
<input type="hidden" name="shoreConnection" value={formData.shoreConnection} />
<input type="hidden" name="reclamationHeightBase" value={formData.reclamationHeightBase} />
<input type="hidden" name="reclamationHeightExtra" value={formData.reclamationHeightExtra} />
<input type="hidden" name="pipelineMain" value={formData.pipelineMain} />
<input type="hidden" name="pipelineExt1" value={formData.pipelineExt1} />
<input type="hidden" name="pipelineReserve" value={formData.pipelineReserve} />
<input type="hidden" name="pipelineExt2" value={formData.pipelineExt2} />
</>
)}
{currentStep !== 3 && (
<>
<input type="hidden" name="statsDozers" value={formData.statsDozers} />
<input type="hidden" name="statsExc" value={formData.statsExc} />
<input type="hidden" name="statsLoaders" value={formData.statsLoaders} />
<input type="hidden" name="statsForeman" value={formData.statsForeman} />
<input type="hidden" name="statsLaborer" value={formData.statsLaborer} />
</>
)}
{currentStep !== 4 && (
<input type="hidden" name="notes" value={formData.notes} />
)}
</Form>
</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1,326 @@
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { sendNotificationEmail } from "~/utils/mail.server";
import { prisma } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
if (!token) {
return json({ showEmailForm: true, token: null, error: null });
}
// Check if token is valid
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
include: { employee: true }
});
if (!resetToken) {
return json({
showEmailForm: false,
token: null,
error: "Invalid reset token. Please request a new password reset."
});
}
if (resetToken.used) {
return json({
showEmailForm: false,
token: null,
error: "This reset token has already been used. Please request a new password reset."
});
}
if (new Date() > resetToken.expiresAt) {
return json({
showEmailForm: false,
token: null,
error: "This reset token has expired. Please request a new password reset."
});
}
return json({
showEmailForm: false,
token,
error: null,
employeeName: resetToken.employee.name
});
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "send-reset-email") {
const email = formData.get("email")?.toString();
if (!email) {
return json({ error: "Email is required" }, { status: 400 });
}
// Find employee by email
const employee = await prisma.employee.findUnique({
where: { email }
});
if (!employee) {
// Don't reveal if email exists or not for security
return json({
success: true,
message: "If an account with this email exists, you will receive a password reset link."
});
}
// Generate reset token
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Save token to database
await prisma.passwordResetToken.create({
data: {
token,
employeeId: employee.id,
expiresAt
}
});
// Send reset email
const resetUrl = `${new URL(request.url).origin}/reset-password?token=${token}`;
const emailResult = await sendNotificationEmail(
employee.email,
"Password Reset Request",
`Hello ${employee.name},\n\nYou requested a password reset. Click the link below to reset your password:\n\n${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you didn't request this, please ignore this email.`,
false
);
if (!emailResult.success) {
return json({ error: "Failed to send reset email. Please try again." }, { status: 500 });
}
return json({
success: true,
message: "If an account with this email exists, you will receive a password reset link."
});
}
if (intent === "reset-password") {
const token = formData.get("token")?.toString();
const password = formData.get("password")?.toString();
const confirmPassword = formData.get("confirmPassword")?.toString();
if (!token || !password || !confirmPassword) {
return json({ error: "All fields are required" }, { status: 400 });
}
if (password !== confirmPassword) {
return json({ error: "Passwords do not match" }, { status: 400 });
}
if (password.length < 6) {
return json({ error: "Password must be at least 6 characters long" }, { status: 400 });
}
// Verify token again
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
include: { employee: true }
});
if (!resetToken || resetToken.used || new Date() > resetToken.expiresAt) {
return json({ error: "Invalid or expired token" }, { status: 400 });
}
// Hash new password
const hashedPassword = await bcrypt.hash(password, 10);
// Update password and mark token as used
await prisma.$transaction([
prisma.employee.update({
where: { id: resetToken.employeeId },
data: { password: hashedPassword }
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
})
]);
return redirect("/signin?message=Password reset successful. Please sign in with your new password.");
}
return json({ error: "Invalid request" }, { status: 400 });
}
export default function ResetPassword() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Password Reset Error
</h2>
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{loaderData.error}</p>
</div>
<div className="mt-4">
<a
href="/reset-password"
className="text-indigo-600 hover:text-indigo-500 font-medium"
>
Request a new password reset
</a>
</div>
</div>
</div>
</div>
);
}
if (loaderData.showEmailForm) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
{actionData?.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{actionData.error}</p>
</div>
)}
{actionData?.success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<p className="text-green-800">{actionData.message}</p>
</div>
)}
<Form method="post" className="mt-8 space-y-6">
<input type="hidden" name="intent" value="send-reset-email" />
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 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 disabled:opacity-50"
>
{isSubmitting ? "Sending..." : "Send reset link"}
</button>
</div>
<div className="text-center">
<a
href="/signin"
className="text-indigo-600 hover:text-indigo-500 font-medium"
>
Back to sign in
</a>
</div>
</Form>
</div>
</div>
);
}
// Show password reset form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
{loaderData.employeeName && `Hello ${loaderData.employeeName}, `}
Enter your new password below.
</p>
</div>
{actionData?.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{actionData.error}</p>
</div>
)}
<Form method="post" className="mt-8 space-y-6">
<input type="hidden" name="intent" value="reset-password" />
<input type="hidden" name="token" value={loaderData.token} />
<div className="space-y-4">
<div>
<label htmlFor="password" className="sr-only">
New Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={6}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="New password (min 6 characters)"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="sr-only">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={6}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Confirm new password"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 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 disabled:opacity-50"
>
{isSubmitting ? "Updating..." : "Update password"}
</button>
</div>
</Form>
</div>
</div>
);
}

144
app/routes/signin.tsx Normal file
View File

@ -0,0 +1,144 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import { createUserSession, getUserId, verifyLogin } from "~/utils/auth.server";
export const meta: MetaFunction = () => [{ title: "Sign In - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/dashboard");
return json({});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const usernameOrEmail = formData.get("usernameOrEmail");
const password = formData.get("password");
const redirectTo = formData.get("redirectTo") || "/dashboard";
if (typeof usernameOrEmail !== "string" || typeof password !== "string" || typeof redirectTo !== "string") {
return json({ errors: { form: "Form not submitted correctly." } }, { status: 400 });
}
if (usernameOrEmail.length === 0) {
return json({ errors: { usernameOrEmail: "Username or email is required" } }, { status: 400 });
}
if (password.length === 0) {
return json({ errors: { password: "Password is required" } }, { status: 400 });
}
const user = await verifyLogin(usernameOrEmail, password);
if (!user) {
return json({ errors: { form: "Invalid username/email or password" } }, { status: 400 });
}
return createUserSession(user.id, redirectTo);
};
export default function SignIn() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") || "/dashboard";
const message = searchParams.get("message");
const actionData = useActionData<typeof action>();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<img
className="mx-auto h-28 w-auto"
src="/clogo-sm.png"
alt="Phosphat Report"
/>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-sm text-gray-600">
Or{" "}
<Link
to="/signup"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
create a new account
</Link>
</p>
</div>
{message && (
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<p className="text-green-800 text-sm">{message}</p>
</div>
)}
<Form method="post" className="mt-8 space-y-6">
<input type="hidden" name="redirectTo" value={redirectTo} />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="usernameOrEmail" className="sr-only">
Username or Email
</label>
<input
id="usernameOrEmail"
name="usernameOrEmail"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Username or Email"
/>
{actionData?.errors?.usernameOrEmail && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.usernameOrEmail}</div>
)}
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
{actionData?.errors?.password && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.password}</div>
)}
</div>
</div>
<div className="flex items-center justify-end">
<div className="text-sm">
<Link
to="/reset-password"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 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"
>
Sign in
</button>
</div>
{actionData?.errors?.form && (
<div className="text-red-500 text-sm text-center">{actionData.errors.form}</div>
)}
</Form>
</div>
</div>
);
}

217
app/routes/signup.tsx Normal file
View File

@ -0,0 +1,217 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData } from "@remix-run/react";
import { createUser, createUserSession, getUserId } from "~/utils/auth.server";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const meta: MetaFunction = () => [{ title: "Sign Up - Phosphat Report" }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/dashboard");
// For first time signup only
// //////////////////////////
const users = await prisma.employee.findMany({
where: { authLevel: 2 },
select: { name: true, username: true, email: true, authLevel: true },
});
if (users.length > 0) {
return redirect("/signin");
}
// //////////////////////////
return json({});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const name = formData.get("name");
const username = formData.get("username");
const email = formData.get("email");
const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword");
if (
typeof name !== "string" ||
typeof username !== "string" ||
typeof email !== "string" ||
typeof password !== "string" ||
typeof confirmPassword !== "string"
) {
return json({ errors: { form: "Form not submitted correctly." } }, { status: 400 });
}
if (name.length === 0) {
return json({ errors: { name: "Name is required" } }, { status: 400 });
}
if (username.length === 0) {
return json({ errors: { username: "Username is required" } }, { status: 400 });
}
if (email.length === 0) {
return json({ errors: { email: "Email is required" } }, { status: 400 });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return json({ errors: { email: "Please enter a valid email address" } }, { status: 400 });
}
if (password.length < 6) {
return json({ errors: { password: "Password must be at least 6 characters" } }, { status: 400 });
}
if (password !== confirmPassword) {
return json({ errors: { confirmPassword: "Passwords do not match" } }, { status: 400 });
}
try {
const user = await createUser(name, username, email, password, 2); // Default auth level 2 (for admin)
return createUserSession(user.id, "/dashboard");
} catch (error) {
return json({ errors: { form: "Username or email already exists" } }, { status: 400 });
}
};
export default function SignUp() {
const actionData = useActionData<typeof action>();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<img
className="mx-auto h-24 w-auto"
src="/clogo-sm.png"
alt="Phosphat Report"
/>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-sm text-gray-600">
Or{" "}
<Link
to="/signin"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
sign in to existing account
</Link>
</p>
</div>
<Form method="post" className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter your full name"
/>
{actionData?.errors?.name && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.name}</div>
)}
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Choose your username"
/>
{actionData?.errors?.username && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.username}</div>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter your email address"
/>
{actionData?.errors?.email && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.email}</div>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Create a password"
/>
{actionData?.errors?.password && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.password}</div>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Confirm your password"
/>
{actionData?.errors?.confirmPassword && (
<div className="text-red-500 text-sm mt-1">{actionData.errors.confirmPassword}</div>
)}
</div>
</div>
{actionData?.errors?.form && (
<div className="text-red-500 text-sm text-center">{actionData.errors.form}</div>
)}
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 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"
>
Create Account
</button>
</div>
</Form>
</div>
</div>
);
}

131
app/routes/test-email.tsx Normal file
View File

@ -0,0 +1,131 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { requireAuthLevel } from "~/utils/auth.server";
import DashboardLayout from "~/components/DashboardLayout";
import { sendNotificationEmail } from "~/utils/mail.server";
export async function loader({ request }: LoaderFunctionArgs) {
// Require auth level 3 to access test email functionality
const user = await requireAuthLevel(request, 3);
return json({user});
}
export async function action({ request }: ActionFunctionArgs) {
await requireAuthLevel(request, 3);
const formData = await request.formData();
const to = formData.get("to") as string;
const subject = formData.get("subject") as string;
const message = formData.get("message") as string;
const isHtml = formData.get("isHtml") === "on";
if (!to || !subject || !message) {
return json({ error: "All fields are required" }, { status: 400 });
}
const result = await sendNotificationEmail(to, subject, message, isHtml);
if (result.success) {
return json({
success: "Email sent successfully!",
messageId: result.messageId
});
} else {
return json({
error: `Failed to send email: ${result.error}`
}, { status: 500 });
}
}
export default function TestEmail() {
const { user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<DashboardLayout user={user}>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Test Email</h1>
<p className="text-gray-600 mb-6">
Use this form to test your email configuration. Only users with auth level 3 can access this feature.
</p>
{actionData?.error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{actionData.success}
{actionData.messageId && (
<div className="text-sm mt-1">Message ID: {actionData.messageId}</div>
)}
</div>
)}
<Form method="post" className="space-y-4">
<div>
<label htmlFor="to" className="block text-sm font-medium text-gray-700">
To Email
</label>
<input
type="email"
id="to"
name="to"
className="mt-1 p-2 h-9 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="recipient@example.com"
required
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700">
Subject
</label>
<input
type="text"
id="subject"
name="subject"
className="mt-1 p-2 h-9 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Test Email Subject"
required
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="message"
name="message"
rows={6}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Enter your test message here..."
required
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
name="isHtml"
className="rounded p-2 h-9 border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">Send as HTML</span>
</label>
</div>
<button
type="submit"
className="w-full 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"
>
Send Test Email
</button>
</Form>
</div>
</DashboardLayout>
);
}

43
app/tailwind.css Normal file
View File

@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
/* Custom animations */
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-slide-down {
animation: slide-down 0.3s ease-out;
}

129
app/utils/auth.server.ts Normal file
View File

@ -0,0 +1,129 @@
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
// Session storage
const sessionSecret = process.env.SESSION_SECRET || "default-secret";
const { getSession, commitSession, destroySession } = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
sameSite: "lax",
secrets: [sessionSecret],
secure: process.env.NODE_ENV === "production",
},
});
export { getSession, commitSession, destroySession };
// Auth functions
export async function createUserSession(userId: number, redirectTo: string) {
const session = await getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
export async function getUserSession(request: Request) {
return getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "number") return null;
return userId;
}
export async function requireUserId(request: Request, redirectTo: string = "/signin") {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([["redirectTo", new URL(request.url).pathname]]);
throw redirect(`${redirectTo}?${searchParams}`);
}
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (!userId) return null;
try {
const user = await prisma.employee.findUnique({
where: { id: userId },
select: { id: true, name: true, username: true, email: true, authLevel: true },
});
return user;
} catch {
throw logout(request);
}
}
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) {
throw redirect("/signin");
}
return user;
}
export async function requireAuthLevel(request: Request, minLevel: number = 2) {
const user = await requireUser(request);
if (user.authLevel < minLevel) {
throw redirect("/signin");
//throw new Response("Unauthorized", { status: 403 });
// redirect to dashboard page
//redirect("/dashboard");
}
return user;
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect("/signin", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
}
export async function verifyLogin(usernameOrEmail: string, password: string) {
// Try to find user by username first, then by email
const user = await prisma.employee.findFirst({
where: {
OR: [
{ username: usernameOrEmail },
{ email: usernameOrEmail }
]
}
});
if (!user || !bcrypt.compareSync(password, user.password)) {
return null;
}
return { id: user.id, username: user.username, email: user.email, name: user.name, authLevel: user.authLevel };
}
export async function createUser(name: string, username: string, email: string, password: string, authLevel: number = 1) {
const hashedPassword = bcrypt.hashSync(password, 10);
const user = await prisma.employee.create({
data: {
name,
username,
email,
password: hashedPassword,
authLevel,
},
});
return { id: user.id, username: user.username, email: user.email, name: user.name, authLevel: user.authLevel };
}

19
app/utils/db.server.ts Normal file
View File

@ -0,0 +1,19 @@
// app/utils/db.server.ts
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient
declare global {
var __db: PrismaClient | undefined
}
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.__db) {
global.__db = new PrismaClient()
}
prisma = global.__db
}
export { prisma }

568
app/utils/excelExport.ts Normal file
View File

@ -0,0 +1,568 @@
import * as ExcelJS from 'exceljs';
import * as FileSaver from 'file-saver';
interface ReportData {
id: number;
createdDate: string;
shift: string;
area: { name: string };
dredgerLocation: { name: string };
dredgerLineLength: number;
reclamationLocation: { name: string };
shoreConnection: number;
reclamationHeight: { base: number; extra: number };
pipelineLength: { main: number; ext1: number; reserve: number; ext2: number };
stats: { Dozers: number; Exc: number; Loaders: number; Foreman: string; Laborer: number };
timeSheet: Array<{
machine: string;
from1: string;
to1: string;
from2: string;
to2: string;
total: string;
reason: string;
}>;
stoppages: Array<{
from: string;
to: string;
total: string;
reason: string;
responsible: string;
note: string;
}>;
notes: string;
}
export async function exportReportToExcel(report: ReportData) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Report');
// Set column widths to match the professional layout from the reference file
worksheet.columns = [
{ width: 30 }, // A - Labels/Machine names
{ width: 20 }, // B - Data/Values
{ width: 20 }, // C - Labels/Data
{ width: 20 }, // D - Data/Values
{ width: 15 }, // E - Pipeline data
{ width: 15 }, // F - Pipeline data
{ width: 25 } // G - Reason/Notes
];
let currentRow = 1;
// 1. HEADER SECTION - Professional layout matching reference file
// Main header with company info
worksheet.mergeCells(`A${currentRow}:E${currentRow + 2}`);
const headerCell = worksheet.getCell(`A${currentRow}`);
headerCell.value = 'Reclamation Work Diary';
headerCell.style = {
font: { name: 'Arial', size: 16, bold: true },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
// Logo area
worksheet.mergeCells(`F${currentRow}:G${currentRow + 2}`);
const logoCell = worksheet.getCell(`F${currentRow}`);
logoCell.value = 'Arab Potash\nCompany Logo';
logoCell.style = {
font: { name: 'Arial', size: 12, bold: true },
alignment: { horizontal: 'center', vertical: 'middle', wrapText: true },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
// Sub-header info
const qfCell = worksheet.getCell(`A${currentRow + 1}`);
qfCell.value = 'QF-3.6.1-08';
qfCell.style = {
font: { name: 'Arial', size: 10 },
alignment: { horizontal: 'center', vertical: 'middle' },
border: {
top: { style: 'thin', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thin', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
const revCell = worksheet.getCell(`A${currentRow + 2}`);
revCell.value = 'Rev. 1.0';
revCell.style = {
font: { name: 'Arial', size: 10 },
alignment: { horizontal: 'center', vertical: 'middle' },
border: {
top: { style: 'thin', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
currentRow += 4; // Skip to next section
// 2. REPORT INFO SECTION - Professional table layout
const infoRowCells = [
{ col: 'A', label: 'Date:', value: new Date(report.createdDate).toLocaleDateString('en-GB') },
{ col: 'C', label: 'Report No.', value: report.id.toString() }
];
// Create bordered info section
['A', 'B', 'C', 'D', 'E', 'F', 'G'].forEach(col => {
const cell = worksheet.getCell(`${col}${currentRow}`);
cell.style = {
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
const dateCell = worksheet.getCell(`A${currentRow}`);
dateCell.value = 'Date:';
dateCell.style = {
font: { name: 'Arial', size: 11, bold: true },
alignment: { horizontal: 'left', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
const dateValueCell = worksheet.getCell(`B${currentRow}`);
dateValueCell.value = new Date(report.createdDate).toLocaleDateString('en-GB');
dateValueCell.style = {
font: { name: 'Arial', size: 11 },
alignment: { horizontal: 'center', vertical: 'middle' },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
const reportNoCell = worksheet.getCell(`E${currentRow}`);
reportNoCell.value = 'Report No.';
reportNoCell.style = {
font: { name: 'Arial', size: 11, bold: true },
alignment: { horizontal: 'left', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
const reportNoValueCell = worksheet.getCell(`F${currentRow}`);
reportNoValueCell.value = report.id.toString();
reportNoValueCell.style = {
font: { name: 'Arial', size: 11 },
alignment: { horizontal: 'center', vertical: 'middle' },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
currentRow += 2; // Skip empty row
// 3. DREDGER SECTION - Professional centered title
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
const dredgerCell = worksheet.getCell(`A${currentRow}`);
dredgerCell.value = `${report.area.name} Dredger`;
dredgerCell.style = {
font: { name: 'Arial', size: 18, bold: true, underline: true },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } }
};
currentRow += 2; // Skip empty row
// 4. LOCATION DATA SECTION - Professional table with green headers
const locationRows = [
['Dredger Location', report.dredgerLocation.name, '', 'Dredger Line Length', report.dredgerLineLength.toString()],
['Reclamation Location', report.reclamationLocation.name, '', 'Shore Connection', report.shoreConnection.toString()],
['Reclamation Height', `${report.reclamationHeight?.base || 0}m - ${(report.reclamationHeight?.base || 0) + (report.reclamationHeight?.extra || 0)}m`, '', '', '']
];
locationRows.forEach((rowData, index) => {
const row = currentRow + index;
// Apply styling to all cells in the row first
for (let col = 1; col <= 7; col++) {
const cell = worksheet.getCell(row, col);
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) => {
if (cellValue !== '') {
const cell = worksheet.getCell(row, colIndex + 1);
cell.value = cellValue;
const isGreenHeader = (colIndex === 0 || colIndex === 3);
cell.style = {
font: { name: 'Arial', size: 11, bold: isGreenHeader, color: isGreenHeader ? { argb: 'FFFFFF' } : { argb: '000000' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: isGreenHeader ? { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } } : { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
}
});
// Merge cells for better layout
if (index === 0) {
worksheet.mergeCells(`B${row}:C${row}`); // Dredger Location value
worksheet.mergeCells(`E${row}:G${row}`); // Dredger Line Length value
} else if (index === 1) {
worksheet.mergeCells(`B${row}:C${row}`); // Reclamation Location value
worksheet.mergeCells(`E${row}:G${row}`); // Shore Connection value
} else if (index === 2) {
worksheet.mergeCells(`B${row}:G${row}`); // Reclamation Height spans all remaining columns
}
});
currentRow += 4; // Skip empty row
// 5. PIPELINE LENGTH SECTION - Professional table with green headers
const pipelineHeaderRow = currentRow;
// First row - main header with rowspan
const mainHeaderCell = worksheet.getCell(pipelineHeaderRow, 1);
mainHeaderCell.value = 'Pipeline Length "from Shore Connection"';
mainHeaderCell.style = {
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
// Sub-headers
const pipelineSubHeaders = ['Main', 'extension', 'total', 'Reserve', 'extension', 'total'];
pipelineSubHeaders.forEach((header, colIndex) => {
const cell = worksheet.getCell(pipelineHeaderRow, colIndex + 2);
cell.value = header;
cell.style = {
font: { name: 'Arial', size: 10, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
// Data row
const pipelineDataRow = currentRow + 1;
const pipelineData = ['',
(report.pipelineLength?.main || 0).toString(),
(report.pipelineLength?.ext1 || 0).toString(),
((report.pipelineLength?.main || 0) + (report.pipelineLength?.ext1 || 0)).toString(),
(report.pipelineLength?.reserve || 0).toString(),
(report.pipelineLength?.ext2 || 0).toString(),
((report.pipelineLength?.reserve || 0) + (report.pipelineLength?.ext2 || 0)).toString()
];
pipelineData.forEach((data, colIndex) => {
const cell = worksheet.getCell(pipelineDataRow, colIndex + 1);
cell.value = data;
cell.style = {
font: { name: 'Arial', size: 11 },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
currentRow += 4; // Skip empty row
// 6. SHIFT HEADER SECTION - Professional full-width header
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
const shiftCell = worksheet.getCell(`A${currentRow}`);
shiftCell.value = `${report.shift.charAt(0).toUpperCase() + report.shift.slice(1)} Shift`;
shiftCell.style = {
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
currentRow += 2; // Skip empty row
// 7. EQUIPMENT STATS SECTION - Professional table with green headers
const equipmentHeaders = ['Dozers', 'Exc.', 'Loader', 'Foreman', 'Laborer'];
// Apply borders to all cells in the equipment section
for (let col = 1; col <= 7; col++) {
for (let row = currentRow; row <= currentRow + 1; row++) {
const cell = worksheet.getCell(row, col);
cell.style = {
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
}
}
equipmentHeaders.forEach((header, colIndex) => {
const cell = worksheet.getCell(currentRow, colIndex + 1);
cell.value = header;
cell.style = {
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
const equipmentData = [
(report.stats?.Dozers || 0).toString(),
(report.stats?.Exc || 0).toString(),
(report.stats?.Loaders || 0).toString(),
report.stats?.Foreman || '',
(report.stats?.Laborer || 0).toString()
];
equipmentData.forEach((data, colIndex) => {
const cell = worksheet.getCell(currentRow + 1, colIndex + 1);
cell.value = data;
cell.style = {
font: { name: 'Arial', size: 11 },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
currentRow += 4; // Skip empty row
// 8. TIME SHEET SECTION - Professional table
const createProfessionalTable = (headers: string[], data: any[][], startRow: number) => {
// Headers
headers.forEach((header, colIndex) => {
const cell = worksheet.getCell(startRow, colIndex + 1);
cell.value = header;
cell.style = {
font: { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
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' } }
}
};
});
// Data rows
data.forEach((rowData, rowIndex) => {
const row = startRow + rowIndex + 1;
rowData.forEach((cellData, colIndex) => {
const cell = worksheet.getCell(row, colIndex + 1);
cell.value = cellData;
cell.style = {
font: { name: 'Arial', size: 10, bold: colIndex === 0 },
alignment: { horizontal: colIndex === 0 ? 'left' : 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
});
});
return startRow + data.length + 1;
};
const timeSheetHeaders = ['Time Sheet', 'From', 'To', 'From', 'To', 'Total', 'Reason'];
const timeSheetData = Array.isArray(report.timeSheet) && report.timeSheet.length > 0
? report.timeSheet.map(entry => [entry.machine, entry.from1, entry.to1, entry.from2, entry.to2, entry.total, entry.reason])
: [['No time sheet entries', '', '', '', '', '', '']];
currentRow = createProfessionalTable(timeSheetHeaders, timeSheetData, currentRow);
currentRow += 2; // Skip empty row
// 9. STOPPAGES SECTION - Professional section with header
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
const stoppagesHeaderCell = worksheet.getCell(`A${currentRow}`);
stoppagesHeaderCell.value = 'Dredger Stoppages';
stoppagesHeaderCell.style = {
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
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 stoppagesHeaders = ['From', 'To', 'Total', 'Reason', 'Responsible', 'Notes', ''];
const stoppagesData = Array.isArray(report.stoppages) && report.stoppages.length > 0
? report.stoppages.map(entry => [entry.from, entry.to, entry.total, entry.reason, entry.responsible, entry.note, ''])
: [['No stoppages recorded', '', '', '', '', '', '']];
currentRow = createProfessionalTable(stoppagesHeaders, stoppagesData, currentRow);
currentRow += 2; // Skip empty row
// 10. NOTES SECTION - Professional notes section
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
const notesHeaderCell = worksheet.getCell(`A${currentRow}`);
notesHeaderCell.value = 'Notes & Comments';
notesHeaderCell.style = {
font: { name: 'Arial', size: 14, bold: true, color: { argb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: '70AD47' } },
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++;
worksheet.mergeCells(`A${currentRow}:G${currentRow + 3}`);
const notesContentCell = worksheet.getCell(`A${currentRow}`);
notesContentCell.value = report.notes || 'No additional notes';
notesContentCell.style = {
font: { name: 'Arial', size: 11 },
alignment: { horizontal: 'left', vertical: 'top', wrapText: true },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF' } },
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 += 6; // Skip to footer
// 11. FOOTER SECTION - Professional footer
worksheet.mergeCells(`A${currentRow}:G${currentRow}`);
const footerCell = worksheet.getCell(`A${currentRow}`);
footerCell.value = 'موقعة لأعمال الصيانة';
footerCell.style = {
font: { name: 'Arial', size: 12, bold: true },
alignment: { horizontal: 'center', vertical: 'middle' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'E6F3FF' } },
border: {
top: { style: 'thick', color: { argb: '000000' } },
left: { style: 'thick', color: { argb: '000000' } },
bottom: { style: 'thick', color: { argb: '000000' } },
right: { style: 'thick', color: { argb: '000000' } }
}
};
// Set row heights for professional appearance
worksheet.eachRow((row, rowNumber) => {
if (rowNumber <= 3) {
row.height = 25; // Header rows
} else if (row.getCell(1).value && typeof row.getCell(1).value === 'string' &&
(row.getCell(1).value.includes('Shift') ||
row.getCell(1).value.includes('Stoppages') ||
row.getCell(1).value.includes('Notes'))) {
row.height = 22; // Section headers
} else {
row.height = 18; // Standard rows
}
});
// Set print settings for professional output
worksheet.pageSetup = {
paperSize: 9, // A4
orientation: 'landscape',
fitToPage: true,
fitToWidth: 1,
fitToHeight: 0,
margins: {
left: 0.7,
right: 0.7,
top: 0.75,
bottom: 0.75,
header: 0.3,
footer: 0.3
}
};
// Generate and save file
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const fileName = `Report_${report.id}_${new Date(report.createdDate).toLocaleDateString('en-GB').replace(/\//g, '-')}.xlsx`;
FileSaver.saveAs(blob, fileName);
}

200
app/utils/mail.server.ts Normal file
View File

@ -0,0 +1,200 @@
import * as nodemailer from "nodemailer";
import { prisma } from "~/utils/db.server";
interface EmailOptions {
to: string | string[];
subject: string;
text?: string;
html?: string;
attachments?: Array<{
filename: string;
content: Buffer | string;
contentType?: string;
}>;
}
export async function sendEmail(options: EmailOptions) {
try {
// Get mail settings from database
const mailSettings = await prisma.mailSettings.findFirst();
if (!mailSettings) {
throw new Error("Mail settings not configured. Please configure SMTP settings first.");
}
// Create transporter with enhanced configuration
const transportConfig: any = {
host: mailSettings.host,
port: mailSettings.port,
secure: mailSettings.secure,
auth: {
user: mailSettings.username,
pass: mailSettings.password,
},
};
// Add additional options for better compatibility
if (!mailSettings.secure && mailSettings.port === 587) {
transportConfig.requireTLS = true;
transportConfig.tls = {
ciphers: 'SSLv3'
};
}
// Add timeout settings
transportConfig.connectionTimeout = 10000;
transportConfig.greetingTimeout = 5000;
transportConfig.socketTimeout = 10000;
const transporter = nodemailer.createTransport(transportConfig);
// Verify connection
await transporter.verify();
// Send email
const result = await transporter.sendMail({
from: `"${mailSettings.fromName}" <${mailSettings.fromEmail}>`,
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
subject: options.subject,
text: options.text,
html: options.html,
attachments: options.attachments,
});
return {
success: true,
messageId: result.messageId,
response: result.response,
};
} catch (error) {
console.error("Failed to send email:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}
export async function testEmailConnection() {
try {
const mailSettings = await prisma.mailSettings.findFirst();
if (!mailSettings) {
return {
success: false,
error: "Mail settings not configured",
};
}
const transportConfig: any = {
host: mailSettings.host,
port: mailSettings.port,
secure: mailSettings.secure, // true for 465, false for other ports
auth: {
user: mailSettings.username,
pass: mailSettings.password,
},
};
// Add additional options for better compatibility
if (!mailSettings.secure && mailSettings.port === 587) {
transportConfig.requireTLS = true;
transportConfig.tls = {
ciphers: 'SSLv3'
};
}
// Add timeout settings
transportConfig.connectionTimeout = 10000; // 10 seconds
transportConfig.greetingTimeout = 5000; // 5 seconds
transportConfig.socketTimeout = 10000; // 10 seconds
console.log('Testing SMTP connection with config:', {
host: mailSettings.host,
port: mailSettings.port,
secure: mailSettings.secure,
user: mailSettings.username
});
const transporter = nodemailer.createTransport(transportConfig);
await transporter.verify();
return {
success: true,
message: "SMTP connection successful",
};
} catch (error) {
console.error('SMTP connection error:', error);
return {
success: false,
error: error instanceof Error ? error.message : "Connection failed",
};
}
}
// Helper function to send notification emails
export async function sendNotificationEmail(
to: string | string[],
subject: string,
message: string,
isHtml: boolean = false
) {
const emailOptions: EmailOptions = {
to,
subject,
};
if (isHtml) {
emailOptions.html = message;
} else {
emailOptions.text = message;
}
return await sendEmail(emailOptions);
}
// Helper function to send report emails with attachments
export async function sendReportEmail(
to: string | string[],
subject: string,
reportData: any,
attachments?: Array<{
filename: string;
content: Buffer | string;
contentType?: string;
}>
) {
const htmlContent = `
<h2>${subject}</h2>
<p>Please find the report details below:</p>
<pre>${JSON.stringify(reportData, null, 2)}</pre>
`;
return await sendEmail({
to,
subject,
html: htmlContent,
attachments,
});
}
// Utility function to clean up expired password reset tokens
export async function cleanupExpiredTokens() {
try {
const result = await prisma.passwordResetToken.deleteMany({
where: {
OR: [
{ expiresAt: { lt: new Date() } },
{ used: true }
]
}
});
console.log(`Cleaned up ${result.count} expired/used password reset tokens`);
return result.count;
} catch (error) {
console.error("Failed to cleanup expired tokens:", error);
return 0;
}
}

157
deploy.sh Normal file
View File

@ -0,0 +1,157 @@
#!/bin/bash
# Phosphat Report Deployment Script
# This script helps deploy the application using Docker Compose
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if .env file exists
if [ ! -f .env ]; then
print_warning ".env file not found. Creating from .env.production template..."
cp .env.production .env
print_warning "Please edit .env file with your production values before continuing!"
exit 1
fi
# Create necessary directories
print_status "Creating necessary directories..."
mkdir -p data backups logs
# Set proper permissions
print_status "Setting directory permissions..."
chmod 755 data backups logs
# Check if Docker and Docker Compose are installed
if ! command -v docker &> /dev/null; then
print_error "Docker is not installed. Please install Docker first."
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
print_error "Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
# Function to deploy
deploy() {
print_status "Starting deployment..."
# Pull latest images (if using external registry)
# docker-compose pull
# Build and start services
print_status "Building and starting services..."
docker-compose up -d --build
# Wait for services to be healthy
print_status "Waiting for services to be healthy..."
sleep 10
# Check if application is running
if docker-compose ps | grep -q "Up"; then
print_success "Application deployed successfully!"
print_status "Application is running at: http://localhost:${APP_PORT:-3000}"
# Show logs
print_status "Recent logs:"
docker-compose logs --tail=20 app
else
print_error "Deployment failed. Check logs:"
docker-compose logs app
exit 1
fi
}
# Function to stop services
stop() {
print_status "Stopping services..."
docker-compose down
print_success "Services stopped."
}
# Function to restart services
restart() {
print_status "Restarting services..."
docker-compose restart
print_success "Services restarted."
}
# Function to show logs
logs() {
docker-compose logs -f app
}
# Function to backup database
backup() {
print_status "Creating database backup..."
timestamp=$(date +%Y%m%d_%H%M%S)
docker-compose exec app cp /app/data/production.db /app/data/backup_${timestamp}.db
print_success "Backup created: backup_${timestamp}.db"
}
# Function to show status
status() {
print_status "Service status:"
docker-compose ps
print_status "Health check:"
curl -s http://localhost:${APP_PORT:-3000}/health | jq . || echo "Health check failed or jq not installed"
}
# Main script logic
case "${1:-deploy}" in
deploy)
deploy
;;
stop)
stop
;;
restart)
restart
;;
logs)
logs
;;
backup)
backup
;;
status)
status
;;
*)
echo "Usage: $0 {deploy|stop|restart|logs|backup|status}"
echo ""
echo "Commands:"
echo " deploy - Build and deploy the application (default)"
echo " stop - Stop all services"
echo " restart - Restart all services"
echo " logs - Show application logs"
echo " backup - Create database backup"
echo " status - Show service status and health"
exit 1
;;
esac

98
docker-compose.yml Normal file
View File

@ -0,0 +1,98 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
- NODE_ENV=production
image: phosphat-report:latest
container_name: phosphat-report-app
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=file:/app/data/production.db
- SESSION_SECRET=${SESSION_SECRET}
- SUPER_ADMIN=${SUPER_ADMIN}
- SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL}
- SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD}
# Mail settings (optional)
- MAIL_HOST=${MAIL_HOST:-}
- MAIL_PORT=${MAIL_PORT:-587}
- MAIL_SECURE=${MAIL_SECURE:-false}
- MAIL_USERNAME=${MAIL_USERNAME:-}
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
- MAIL_FROM_NAME=${MAIL_FROM_NAME:-Phosphat Report System}
- MAIL_FROM_EMAIL=${MAIL_FROM_EMAIL:-}
volumes:
- app_data:/app/data
- app_logs:/app/logs
networks:
- app_network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
labels:
- "com.docker.compose.service=phosphat-report"
- "com.docker.compose.project=phosphat-report"
- "traefik.enable=true"
- "traefik.http.routers.phosphat-report.rule=Host(`${DOMAIN:-localhost}`)"
- "traefik.http.routers.phosphat-report.tls=true"
- "traefik.http.routers.phosphat-report.tls.certresolver=letsencrypt"
- "traefik.http.services.phosphat-report.loadbalancer.server.port=3000"
# Database backup service (optional but recommended)
backup:
image: alpine:latest
container_name: phosphat-report-backup
restart: unless-stopped
volumes:
- app_data:/data:ro
- backup_data:/backup
environment:
- BACKUP_SCHEDULE=${BACKUP_SCHEDULE:-0 2 * * *}
command: >
sh -c "
apk add --no-cache dcron sqlite &&
echo '${BACKUP_SCHEDULE:-0 2 * * *} cp /data/production.db /backup/production_$(date +%Y%m%d_%H%M%S).db && find /backup -name \"production_*.db\" -mtime +7 -delete' | crontab - &&
crond -f
"
networks:
- app_network
depends_on:
- app
volumes:
app_data:
driver: local
driver_opts:
type: none
o: bind
device: ${DATA_PATH:-./data}
app_logs:
driver: local
backup_data:
driver: local
driver_opts:
type: none
o: bind
device: ${BACKUP_PATH:-./backups}
networks:
app_network:
driver: bridge

14070
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "phosphat-report-v2",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@prisma/client": "^6.12.0",
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"bcryptjs": "^2.4.3",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"isbot": "^4.1.0",
"nodemailer": "^7.0.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@remix-run/dev": "^2.16.8",
"@types/bcryptjs": "^2.4.6",
"@types/file-saver": "^2.0.7",
"@types/nodemailer": "^6.4.17",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",
"eslint": "^8.38.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.38",
"prisma": "^6.12.0",
"tailwindcss": "^3.4.4",
"tsx": "^4.20.3",
"typescript": "^5.1.6",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": ">=20.0.0"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "Report" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"employeeId" INTEGER NOT NULL,
"createdDate" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedDate" DATETIME NOT NULL,
"shift" TEXT NOT NULL,
"areaId" INTEGER NOT NULL,
"dredgerLocationId" INTEGER NOT NULL,
"dredgerLineLength" INTEGER NOT NULL,
"reclamationLocationId" INTEGER NOT NULL,
"shoreConnection" INTEGER NOT NULL,
"reclamationHeight" JSONB NOT NULL,
"pipelineLength" JSONB NOT NULL,
"stats" JSONB NOT NULL,
"timeSheet" JSONB NOT NULL,
"stoppages" JSONB NOT NULL,
"notes" TEXT,
CONSTRAINT "Report_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Report_areaId_fkey" FOREIGN KEY ("areaId") REFERENCES "Area" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Report_dredgerLocationId_fkey" FOREIGN KEY ("dredgerLocationId") REFERENCES "DredgerLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Report_reclamationLocationId_fkey" FOREIGN KEY ("reclamationLocationId") REFERENCES "ReclamationLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Area" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "DredgerLocation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"class" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "ReclamationLocation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Employee" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"authLevel" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Foreman" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Equipment" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"category" TEXT NOT NULL,
"model" TEXT NOT NULL,
"number" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Area_name_key" ON "Area"("name");
-- CreateIndex
CREATE UNIQUE INDEX "DredgerLocation_name_key" ON "DredgerLocation"("name");
-- CreateIndex
CREATE UNIQUE INDEX "ReclamationLocation_name_key" ON "ReclamationLocation"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Employee_username_key" ON "Employee"("username");

View File

@ -0,0 +1 @@
-- This is an empty migration.

View File

@ -0,0 +1,24 @@
/*
Warnings:
- Added the required column `email` to the `Employee` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Employee" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"authLevel" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL
);
INSERT INTO "new_Employee" ("authLevel", "id", "name", "password", "username") SELECT "authLevel", "id", "name", "password", "username" FROM "Employee";
DROP TABLE "Employee";
ALTER TABLE "new_Employee" RENAME TO "Employee";
CREATE UNIQUE INDEX "Employee_username_key" ON "Employee"("username");
CREATE UNIQUE INDEX "Employee_email_key" ON "Employee"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "MailSettings" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"host" TEXT NOT NULL,
"port" INTEGER NOT NULL,
"secure" BOOLEAN NOT NULL DEFAULT false,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"fromName" TEXT NOT NULL,
"fromEmail" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"token" TEXT NOT NULL,
"employeeId" INTEGER NOT NULL,
"expiresAt" DATETIME NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

99
prisma/schema.prisma Normal file
View File

@ -0,0 +1,99 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Report {
id Int @id @default(autoincrement())
employeeId Int
employee Employee @relation(fields: [employeeId], references: [id])
createdDate DateTime @default(now())
updatedDate DateTime @updatedAt
shift String // 'day' or 'night'
areaId Int
area Area @relation(fields: [areaId], references: [id])
dredgerLocationId Int
dredgerLocation DredgerLocation @relation(fields: [dredgerLocationId], references: [id])
dredgerLineLength Int
reclamationLocationId Int
reclamationLocation ReclamationLocation @relation(fields: [reclamationLocationId], references: [id])
shoreConnection Int
reclamationHeight Json // JSON: { base: int, extra: int }
pipelineLength Json // JSON: { main: int, ext1: int, reserve: int, ext2: int }
stats Json // JSON: { Dozers: int, Exc: int, Loaders: int, Foreman: string, Laborer: int }
timeSheet Json // JSON: Array of timesheet objects
stoppages Json // JSON: Array of stoppage records
notes String?
}
model Area {
id Int @id @default(autoincrement())
name String @unique
reports Report[]
}
model DredgerLocation {
id Int @id @default(autoincrement())
name String @unique
class String // 's', 'd', 'sp'
reports Report[]
}
model ReclamationLocation {
id Int @id @default(autoincrement())
name String @unique
reports Report[]
}
model Employee {
id Int @id @default(autoincrement())
name String
authLevel Int
username String @unique
email String @unique
password String
reports Report[]
passwordResetTokens PasswordResetToken[]
}
model PasswordResetToken {
id Int @id @default(autoincrement())
token String @unique
employeeId Int
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}
model Foreman {
id Int @id @default(autoincrement())
name String
}
model Equipment {
id Int @id @default(autoincrement())
category String
model String
number Int
}
model MailSettings {
id Int @id @default(autoincrement())
host String
port Int
secure Boolean @default(false)
username String
password String
fromName String
fromEmail String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

158
prisma/seed.ts Normal file
View File

@ -0,0 +1,158 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Seeding database...')
// Seed Areas
const areas = await Promise.all([
prisma.area.upsert({
where: { name: 'Petra' },
update: {},
create: { name: 'Petra' }
}),
prisma.area.upsert({
where: { name: 'Jarash' },
update: {},
create: { name: 'Jarash' }
}),
prisma.area.upsert({
where: { name: 'Rum' },
update: {},
create: { name: 'Rum' }
})
])
// Seed DredgerLocations
const dredgerLocations = await Promise.all([
prisma.dredgerLocation.upsert({
where: { name: 'SP1-1' },
update: {},
create: { name: 'SP1-1', class: 'SP' }
}),
prisma.dredgerLocation.upsert({
where: { name: 'SP1-2' },
update: {},
create: { name: 'SP1-2', class: 'SP' }
}),
prisma.dredgerLocation.upsert({
where: { name: 'C01' },
update: {},
create: { name: 'C01', class: 'C' }
}),
prisma.dredgerLocation.upsert({
where: { name: 'D1' },
update: {},
create: { name: 'D1', class: 'D' }
})
])
// Seed ReclamationLocations
const reclamationLocations = await Promise.all([
prisma.reclamationLocation.upsert({
where: { name: 'Eastern Shoreline' },
update: {},
create: { name: 'Eastern Shoreline' }
}),
prisma.reclamationLocation.upsert({
where: { name: 'Western Shoreline' },
update: {},
create: { name: 'Western Shoreline' }
})
])
// Seed Employee
// const employee = await prisma.employee.upsert({
// where: { username: 'superuser' },
// update: {},
// create: {
// name: 'Super Admin User',
// authLevel: 3,
// username: '',
// email: '@gmail.com',
// password: bcrypt.hashSync('', 10)
// }
// })
// Seed Employee
//use the .env file SUPER_ADMIN, SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD
const superAdmin = await prisma.employee.upsert({
where: { username: process.env.SUPER_ADMIN },
update: {},
create: {
name: 'Super Admin User',
authLevel: 3,
username: process.env.SUPER_ADMIN,
email: process.env.SUPER_ADMIN_EMAIL,
password: bcrypt.hashSync(process.env.SUPER_ADMIN_PASSWORD, 10)
}
})
// Seed Foreman
const foreman = await prisma.foreman.upsert({
where: { id: 1 },
update: {},
create: {
name: 'John Smith'
}
})
// Seed Equipment
const equipment = await Promise.all([
prisma.equipment.upsert({
where: { id: 1 },
update: {},
create: { id: 1, category: 'Dozer', model: 'Dozer6', number: 1 }
}),
prisma.equipment.upsert({
where: { id: 2 },
update: {},
create: { id: 2, category: 'Dozer', model: 'Dozer6', number: 2 }
}),
prisma.equipment.upsert({
where: { id: 3 },
update: {},
create: { id: 3, category: 'Dozer', model: 'Dozer7', number: 1 }
}),
prisma.equipment.upsert({
where: { id: 4 },
update: {},
create: { id: 4, category: 'Dozer', model: 'Dozer8', number: 1 }
}),
prisma.equipment.upsert({
where: { id: 5 },
update: {},
create: { id: 5, category: 'Loader', model: 'Loader', number: 1 }
}),
prisma.equipment.upsert({
where: { id: 6 },
update: {},
create: { id: 6, category: 'Excavator', model: 'Exc.', number: 1 }
}),
prisma.equipment.upsert({
where: { id: 7 },
update: {},
create: { id: 7, category: 'Excavator', model: 'Exc.', number: 9 }
})
])
console.log('✅ Database seeded successfully!')
console.log(`Created ${areas.length} areas`)
console.log(`Created ${dredgerLocations.length} dredger locations`)
console.log(`Created ${reclamationLocations.length} reclamation locations`)
console.log(`Created 1 employee`)
console.log(`Created 1 foreman`)
console.log(`Created ${equipment.length} equipment records`)
}
main()
.catch((e) => {
console.error('❌ Error seeding database:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

BIN
public/alhaffer-logo-en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/clogo-sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
public/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

22
tailwind.config.ts Normal file
View File

@ -0,0 +1,22 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
},
},
plugins: [],
} satisfies Config;

32
tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// Vite takes care of building everything, not tsc.
"noEmit": true
}
}

24
vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
declare module "@remix-run/node" {
interface Future {
v3_singleFetch: true;
}
}
export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});