2est-end
This commit is contained in:
parent
3bd8e3a5ce
commit
9131588936
71
.dockerignore
Normal file
71
.dockerignore
Normal 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
38
.env.production
Normal 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
84
.eslintrc.cjs
Normal 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
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
|
||||
/generated/prisma
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.autoClosingTags": false
|
||||
}
|
||||
236
DEPLOYMENT.md
Normal file
236
DEPLOYMENT.md
Normal 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
87
Dockerfile
Normal 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"]
|
||||
221
app/components/DashboardLayout.tsx
Normal file
221
app/components/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
app/components/FormModal.tsx
Normal file
113
app/components/FormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
753
app/components/ReportFormModal.tsx
Normal file
753
app/components/ReportFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
app/components/ReportViewModal.tsx
Normal file
407
app/components/ReportViewModal.tsx
Normal 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
56
app/components/Toast.tsx
Normal 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
18
app/entry.client.tsx
Normal 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
140
app/entry.server.tsx
Normal 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
45
app/root.tsx
Normal 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
11
app/routes/_index.tsx
Normal 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
283
app/routes/areas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
app/routes/dashboard.$.tsx
Normal file
26
app/routes/dashboard.$.tsx
Normal 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
183
app/routes/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
app/routes/dredger-locations.tsx
Normal file
381
app/routes/dredger-locations.tsx
Normal 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
549
app/routes/employees.tsx
Normal 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
369
app/routes/equipment.tsx
Normal 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
276
app/routes/foreman.tsx
Normal 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
37
app/routes/health.tsx
Normal 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
10
app/routes/logout.tsx
Normal 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);
|
||||
};
|
||||
266
app/routes/mail-settings.tsx
Normal file
266
app/routes/mail-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
app/routes/reclamation-locations.tsx
Normal file
281
app/routes/reclamation-locations.tsx
Normal 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
676
app/routes/reports.tsx
Normal 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
736
app/routes/reports_.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
app/routes/reset-password.tsx
Normal file
326
app/routes/reset-password.tsx
Normal 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
144
app/routes/signin.tsx
Normal 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
217
app/routes/signup.tsx
Normal 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
131
app/routes/test-email.tsx
Normal 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
43
app/tailwind.css
Normal 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
129
app/utils/auth.server.ts
Normal 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
19
app/utils/db.server.ts
Normal 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
568
app/utils/excelExport.ts
Normal 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
200
app/utils/mail.server.ts
Normal 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
157
deploy.sh
Normal 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
98
docker-compose.yml
Normal 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
14070
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
77
prisma/migrations/20250719155324_init/migration.sql
Normal file
77
prisma/migrations/20250719155324_init/migration.sql
Normal 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");
|
||||
@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@ -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;
|
||||
@ -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
|
||||
);
|
||||
@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
99
prisma/schema.prisma
Normal 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
158
prisma/seed.ts
Normal 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
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
BIN
public/clogo-sm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/logo-dark.png
Normal file
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
BIN
public/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
22
tailwind.config.ts
Normal file
22
tailwind.config.ts
Normal 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
32
tsconfig.json
Normal 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
24
vite.config.ts
Normal 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(),
|
||||
],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user