v2.0.0: Complete rewrite — React + Express + PostgreSQL + Docker
BREAKING: Full rewrite from static HTML to production-grade stack. Features: - React 18 + Vite + Tailwind CSS (mobile-first) - Express + Prisma + PostgreSQL backend - JWT authentication with role-based access - Weekly Mon-Sun timesheets with auto-save - Multiple homeowner entries per day - Submit → Approve/Reject workflow - Server-side PDF generation - SMTP email integration - Admin panel with reporting & filters - Dark mode (system-aware) - Docker Compose one-command deploy - Non-root containers, Helmet, bcrypt, Zod validation
@@ -1,61 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# IDE files
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Docker files (to avoid infinite recursion)
|
|
||||||
Dockerfile*
|
|
||||||
docker-compose*
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# ESLint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Temporary folders
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ─── Database ───────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://coastal:coastal_secret@localhost:5432/coastal_timesheet
|
||||||
|
|
||||||
|
# ─── JWT Secrets ────────────────────────────────────────
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
JWT_SECRET=change-me-to-a-random-64-byte-hex-string
|
||||||
|
JWT_REFRESH_SECRET=change-me-to-a-different-random-64-byte-hex-string
|
||||||
|
|
||||||
|
# ─── Server ────────────────────────────────────────────
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ─── SMTP (Email) ──────────────────────────────────────
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
# ─── Admin ──────────────────────────────────────────────
|
||||||
|
ADMIN_EMAIL=bizzle@coastalcontracting.com
|
||||||
@@ -1,88 +1,6 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# IDE and editor files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS generated files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
# Runtime data
|
.prisma/
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# next.js build output
|
|
||||||
.next
|
|
||||||
|
|
||||||
# nuxt.js build output
|
|
||||||
.nuxt
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
|
|
||||||
# Production zip file (optional - you might want to include this)
|
|
||||||
coastal-timesheet-production.zip
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# Docker Deployment Guide
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
```bash
|
|
||||||
# Build and run the production container
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Access the app at http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Build
|
|
||||||
```bash
|
|
||||||
# Build the Docker image
|
|
||||||
docker build -t coastal-timesheet .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -d -p 8080:80 --name coastal-timesheet coastal-timesheet
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development with Docker
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
```bash
|
|
||||||
# Run development environment
|
|
||||||
docker-compose -f docker-compose.dev.yml up
|
|
||||||
|
|
||||||
# Access the app at http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Commands
|
|
||||||
|
|
||||||
### Production
|
|
||||||
```bash
|
|
||||||
# Start services
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Stop services
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Rebuild after changes
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
# Start dev environment
|
|
||||||
docker-compose -f docker-compose.dev.yml up
|
|
||||||
|
|
||||||
# Stop dev environment
|
|
||||||
docker-compose -f docker-compose.dev.yml down
|
|
||||||
|
|
||||||
# Rebuild dev container
|
|
||||||
docker-compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
- `NODE_ENV`: Set to "production" for production builds
|
|
||||||
- Port mapping can be changed in docker-compose.yml
|
|
||||||
|
|
||||||
### Nginx Configuration
|
|
||||||
The production container uses nginx to serve static files with:
|
|
||||||
- Gzip compression enabled
|
|
||||||
- Client-side routing support (SPA)
|
|
||||||
- Static asset caching
|
|
||||||
- Security headers
|
|
||||||
|
|
||||||
### Ports
|
|
||||||
- **Production**: http://localhost:8080
|
|
||||||
- **Development**: http://localhost:5173
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The production setup uses a multi-stage build:
|
|
||||||
1. **Build stage**: Compiles the React app using Node.js
|
|
||||||
2. **Production stage**: Serves static files using nginx
|
|
||||||
|
|
||||||
This results in a lightweight production image (~25MB) that only contains the compiled app and nginx.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Use Node.js 18 alpine as base image
|
|
||||||
FROM node:18-alpine as build
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage - use nginx to serve static files
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy built files from build stage
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy custom nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port 80
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Development Dockerfile
|
|
||||||
FROM node:18-alpine
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies)
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose development port
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
|
||||||
@@ -1,189 +1,287 @@
|
|||||||
# Coastal Contracting Timesheet App
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/01-login-mobile.png" alt="Coastal Timesheet" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A modern, responsive timesheet application built with React for tracking work hours and generating professional PDF timesheets. Features comprehensive validation, dynamic field layouts, and seamless data management.
|
<h1 align="center">Coastal Timesheet v2</h1>
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
*Screenshot showing the timesheet interface with validation, dynamic fields, and professional layout*
|
<strong>A modern, mobile-first time tracking application for Coastal Contracting of FL</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=white" alt="React 18" />
|
||||||
|
<img src="https://img.shields.io/badge/Express-4-000000?logo=express&logoColor=white" alt="Express" />
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
|
||||||
|
<img src="https://img.shields.io/badge/Prisma-ORM-2D3748?logo=prisma&logoColor=white" alt="Prisma" />
|
||||||
|
<img src="https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||||
|
<img src="https://img.shields.io/badge/TailwindCSS-3-06B6D4?logo=tailwindcss&logoColor=white" alt="Tailwind" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### 📊 **Time Tracking & Management**
|
- **📱 Mobile-first design** — Built for field workers, optimized for phones
|
||||||
- **Weekly Time Tracking**: Track work hours for each day of the week with multiple entries per day
|
- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting
|
||||||
- **Dynamic Date Navigation**: Navigate between weeks with intuitive date picker controls
|
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
|
||||||
- **Flexible Entry Management**: Add, remove, and modify time entries as needed
|
- **🏠 Multiple homeowners per day** — Track work at different job sites
|
||||||
- **Auto-saving**: Automatically saves all data to browser localStorage
|
- **✅ Submit → Approve workflow** — Employees submit, admins approve or reject
|
||||||
|
- **📄 PDF generation** — Professional server-side PDF export
|
||||||
|
- **📧 Email integration** — Send timesheets via email with SMTP
|
||||||
|
- **👥 Admin panel** — Manage employees, homeowners, review timesheets
|
||||||
|
- **📊 Reporting** — Filter by employee, date range, status with summary stats
|
||||||
|
- **🌙 Dark mode** — System-aware with manual toggle
|
||||||
|
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
|
||||||
|
|
||||||
### 🏠 **Homeowner Management**
|
---
|
||||||
- **Pre-loaded Homeowners**: Comprehensive list of default homeowner names and addresses
|
|
||||||
- **Custom Homeowners**: Add and save custom homeowner names that persist across sessions
|
|
||||||
- **Smart Dropdown**: Searchable dropdown with autocomplete functionality
|
|
||||||
|
|
||||||
### ✅ **Smart Validation System**
|
## 📸 Screenshots
|
||||||
- **Required Employee Name**: Employee name must be entered before exporting timesheets
|
|
||||||
- **Complete Entry Validation**: When any field is filled, all three fields (homeowner, hours, work description) become required
|
|
||||||
- **Visual Feedback**: Red borders, asterisks, and warning messages guide users to complete entries
|
|
||||||
- **Export Protection**: PDF generation disabled until all validation requirements are met
|
|
||||||
|
|
||||||
### 📝 **Enhanced User Interface**
|
<table>
|
||||||
- **Dynamic Work Description Fields**: Auto-resizing text areas that expand to show all content without scrolling
|
<tr>
|
||||||
- **Optimized Field Layout**: Homeowner → Hours → Work Description order with maximum space for descriptions
|
<td align="center"><strong>Timesheet Entry</strong></td>
|
||||||
- **Today Highlighting**: Current day is visually highlighted for easy identification
|
<td align="center"><strong>Entry Form</strong></td>
|
||||||
- **Dark/Light Mode**: Toggle between themes with persistent user preference
|
<td align="center"><strong>Dark Mode</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/02-timesheet-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/07-dark-mode-mobile.png" width="250" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
### 📄 **Professional PDF Export**
|
<table>
|
||||||
- **Download PDF**: Generate and download professional timesheet PDFs
|
<tr>
|
||||||
- **Email Integration**: Share PDFs via device share sheet or download with pre-filled email subject
|
<td align="center"><strong>Admin Panel</strong></td>
|
||||||
- **Optimized Layout**: PDF layout matches UI with proper field sizing and professional formatting
|
<td align="center"><strong>Reports & Filters</strong></td>
|
||||||
- **Validation Integration**: Only complete, valid timesheets can be exported
|
<td align="center"><strong>History</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/05-admin-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/06-admin-reports-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/04-history-mobile.png" width="250" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
### 💾 **Data Management**
|
### Desktop
|
||||||
- **Import/Export**: Backup and restore timesheet data with JSON export/import
|
|
||||||
- **Week-based Storage**: Each week's data stored separately for better organization
|
|
||||||
- **Custom Homeowner Persistence**: Added homeowners saved across all sessions
|
|
||||||
- **Cross-device Compatibility**: Works on desktop, tablet, and mobile devices
|
|
||||||
|
|
||||||
### 📱 **Responsive Design**
|
<img src="docs/screenshots/02-timesheet-desktop.png" width="700" />
|
||||||
- **Mobile-first**: Optimized for touch interfaces and small screens
|
<img src="docs/screenshots/06-admin-reports-desktop.png" width="700" />
|
||||||
- **Adaptive Layouts**: Fields reorganize appropriately for different screen sizes
|
|
||||||
- **Touch-friendly**: Large buttons and touch targets for mobile users
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (version 16 or higher)
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||||
- npm or yarn
|
- That's it. Everything else runs in containers.
|
||||||
|
|
||||||
### Installation
|
### Deploy
|
||||||
|
|
||||||
1. Clone or download the project files
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the development server:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Open [http://localhost:5173](http://localhost:5173) in your browser
|
|
||||||
|
|
||||||
### Building for Production
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
# Clone the repo
|
||||||
|
git clone https://git.bizzle.lol/bizzle/coastal_timesheet.git
|
||||||
|
cd coastal_timesheet
|
||||||
|
git checkout v2
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your secrets (see Configuration below)
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
cd docker
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The built files will be in the `dist/` directory, ready for web hosting.
|
The app will be available at `http://localhost` (port 80).
|
||||||
|
|
||||||
### Preview Production Build
|
### Default Admin Account
|
||||||
|
|
||||||
```bash
|
| Field | Value |
|
||||||
npm run preview
|
|----------|-------------------------|
|
||||||
|
| Email | `admin@coastal.com` |
|
||||||
|
| Password | `CoastalAdmin2026!` |
|
||||||
|
|
||||||
|
> ⚠️ **Change the admin password after first login.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (auto-configured in Docker)
|
||||||
|
DB_PASSWORD=your-secure-db-password
|
||||||
|
|
||||||
|
# JWT Secrets (CHANGE THESE!)
|
||||||
|
JWT_SECRET=your-jwt-secret-min-32-chars
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars
|
||||||
|
|
||||||
|
# CORS (add your domain)
|
||||||
|
CORS_ORIGINS=https://timesheets.yourdomain.com
|
||||||
|
|
||||||
|
# Email (optional — for sending timesheets)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=timesheets@yourdomain.com
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Usage Guide
|
### Email Setup (Gmail)
|
||||||
|
|
||||||
### 1. **Setup**
|
1. Enable 2FA on your Google account
|
||||||
- Enter your **Employee Name** (required for all exports)
|
2. Go to [App Passwords](https://myaccount.google.com/apppasswords)
|
||||||
- Select the week you want to track using the date picker
|
3. Generate a new app password for "Mail"
|
||||||
|
4. Use that as `SMTP_PASS`
|
||||||
|
|
||||||
### 2. **Adding Time Entries**
|
---
|
||||||
- Click "Add Entry" for any day to create multiple entries
|
|
||||||
- Fill in all three fields for each entry:
|
|
||||||
- **Homeowner**: Select from dropdown or add custom names
|
|
||||||
- **Hours Worked**: Enter decimal hours (e.g., 2.5 for 2 hours 30 minutes)
|
|
||||||
- **Work Description**: Detailed description of work performed (auto-expanding field)
|
|
||||||
|
|
||||||
### 3. **Validation & Completion**
|
## 🏗️ Architecture
|
||||||
- **Red asterisks (*)** indicate required fields
|
|
||||||
- **Warning messages** appear for incomplete entries
|
|
||||||
- **Export buttons** are disabled until all requirements are met
|
|
||||||
|
|
||||||
### 4. **Export Options**
|
```
|
||||||
- **Download PDF**: Save a printable PDF to your device
|
┌─────────────────────────────────────────────┐
|
||||||
- **Share/Email PDF**: Use device share functionality or download with email setup
|
│ Nginx │
|
||||||
- **Import/Export Data**: Backup/restore your timesheet data
|
│ (reverse proxy) │
|
||||||
|
│ /api/* → backend:3001 │
|
||||||
|
│ /* → static frontend │
|
||||||
|
├──────────────────┬──────────────────────────┤
|
||||||
|
│ Frontend │ Backend │
|
||||||
|
│ React + Vite │ Express + Prisma │
|
||||||
|
│ Tailwind CSS │ JWT Auth │
|
||||||
|
│ Lucide Icons │ @react-pdf/renderer │
|
||||||
|
│ │ Nodemailer │
|
||||||
|
│ ├──────────────────────────┤
|
||||||
|
│ │ PostgreSQL 16 │
|
||||||
|
│ │ (persistent volume) │
|
||||||
|
└──────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### 5. **Additional Features**
|
### Tech Stack
|
||||||
- **Theme Toggle**: Switch between light and dark modes
|
|
||||||
- **Office Contact**: Download contact card for easy email setup
|
|
||||||
|
|
||||||
## 🗂️ Data Storage
|
| Layer | Technology |
|
||||||
|
|-----------|-----------------------------------------------|
|
||||||
|
| Frontend | React 18, Vite 6, Tailwind CSS 3, Lucide |
|
||||||
|
| Backend | Express 4, Prisma ORM, bcryptjs, jsonwebtoken |
|
||||||
|
| Database | PostgreSQL 16 (Alpine) |
|
||||||
|
| PDF | @react-pdf/renderer (server-side) |
|
||||||
|
| Email | Nodemailer + SMTP |
|
||||||
|
| Proxy | Nginx 1.27 (Alpine) |
|
||||||
|
| Container | Docker Compose v3.9 |
|
||||||
|
|
||||||
- **Local Browser Storage**: All data stored locally, no external servers
|
---
|
||||||
- **Weekly Organization**: Each week stored as separate dataset
|
|
||||||
- **Persistent Settings**: Theme preferences and custom homeowners preserved
|
|
||||||
- **Privacy-focused**: Your data never leaves your device
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
- **Frontend Framework**: React 18 with Vite
|
|
||||||
- **Styling**: Tailwind CSS with responsive design
|
|
||||||
- **PDF Generation**: @react-pdf/renderer for professional documents
|
|
||||||
- **Icons**: Lucide React icon library
|
|
||||||
- **Storage**: Browser localStorage API
|
|
||||||
- **Build Tool**: Vite for fast development and optimized builds
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
.
|
||||||
├── components/ # React components
|
├── frontend/ # React SPA
|
||||||
│ ├── TimeSheet.jsx # Main timesheet interface
|
│ ├── src/
|
||||||
│ ├── DayEntries.jsx # Day-specific entry management
|
│ │ ├── api/ # Axios client with token refresh
|
||||||
│ ├── TimeEntryRow.jsx # Individual entry row (legacy)
|
│ │ ├── components/ # Reusable UI components
|
||||||
│ ├── DatePicker.jsx # Week navigation
|
│ │ ├── contexts/ # Auth context (JWT)
|
||||||
│ ├── HomeownerDropdown.jsx # Homeowner selection
|
│ │ ├── hooks/ # Custom hooks (theme, swipe, auto-save)
|
||||||
│ ├── ThemeToggle.jsx # Dark/light mode toggle
|
│ │ └── pages/ # Route pages
|
||||||
│ ├── PDFExport.jsx # PDF download functionality
|
│ └── vite.config.js
|
||||||
│ ├── EmailPDF.jsx # PDF sharing functionality
|
├── backend/ # Express API
|
||||||
│ ├── TimesheetPDF.jsx # PDF document structure
|
│ ├── prisma/
|
||||||
│ ├── ImportExport.jsx # Data backup/restore
|
│ │ ├── schema.prisma # Database schema
|
||||||
│ └── MiniCalendar.jsx # Calendar widget
|
│ │ └── seed.js # Seed admin + homeowners
|
||||||
├── hooks/ # Custom React hooks
|
│ └── src/
|
||||||
│ ├── useTimeSheet.js # Timesheet state & validation
|
│ ├── middleware/ # Auth middleware
|
||||||
│ └── useTheme.js # Theme management
|
│ ├── routes/ # API routes
|
||||||
├── utils/ # Utility functions
|
│ └── utils/ # PDF, email, validation
|
||||||
│ └── dateUtils.js # Date manipulation helpers
|
├── docker/ # Deployment
|
||||||
├── App.jsx # Main application component
|
│ ├── docker-compose.yml
|
||||||
├── main.jsx # Application entry point
|
│ ├── backend/Dockerfile
|
||||||
└── index.css # Global styles
|
│ ├── frontend/Dockerfile
|
||||||
|
│ └── nginx/default.conf
|
||||||
|
└── docs/screenshots/ # App screenshots
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Configuration Files
|
|
||||||
|
|
||||||
- `vite.config.js` - Vite build configuration
|
|
||||||
- `tailwind.config.js` - Tailwind CSS customization
|
|
||||||
- `postcss.config.js` - PostCSS processing
|
|
||||||
- `package.json` - Dependencies and scripts
|
|
||||||
|
|
||||||
## 🌐 Deployment
|
|
||||||
|
|
||||||
The built application is a static site that can be deployed to any web hosting service:
|
|
||||||
|
|
||||||
1. Run `npm run build`
|
|
||||||
2. Upload the contents of the `dist/` directory to your web server
|
|
||||||
3. Ensure your server serves `index.html` for all routes (SPA configuration)
|
|
||||||
|
|
||||||
Compatible with: Netlify, Vercel, GitHub Pages, traditional web hosting, and more.
|
|
||||||
|
|
||||||
## 📋 Features Summary
|
|
||||||
|
|
||||||
✅ **Employee name validation**
|
|
||||||
✅ **Complete entry validation (all fields required when any field has input)**
|
|
||||||
✅ **Dynamic auto-resizing work description fields**
|
|
||||||
✅ **Optimized field order (Homeowner → Hours → Work Description)**
|
|
||||||
✅ **Professional PDF generation with validation**
|
|
||||||
✅ **Email/share functionality**
|
|
||||||
✅ **Data import/export**
|
|
||||||
✅ **Dark/light theme support**
|
|
||||||
✅ **Mobile-responsive design**
|
|
||||||
✅ **Local data persistence**
|
|
||||||
✅ **Multiple entries per day**
|
|
||||||
✅ **Custom homeowner management**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Built for Coastal Contracting of FL - Making timesheet management simple and professional.*
|
## 🔒 Security
|
||||||
|
|
||||||
|
- **bcrypt** password hashing (12 rounds)
|
||||||
|
- **JWT** access tokens (15min) + refresh tokens (7 days)
|
||||||
|
- **Helmet** security headers
|
||||||
|
- **Rate limiting** on auth endpoints (50 req / 15 min)
|
||||||
|
- **Zod** input validation on all endpoints
|
||||||
|
- **Prisma ORM** — parameterized queries (no SQL injection)
|
||||||
|
- **Non-root Docker** containers
|
||||||
|
- **CORS** origin whitelist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------------------|----------------------|
|
||||||
|
| POST | `/api/auth/login` | Login, get tokens |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
|
| GET | `/api/auth/me` | Current user info |
|
||||||
|
|
||||||
|
### Entries
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|---------------------|------------------------|
|
||||||
|
| GET | `/api/entries` | List entries (by week) |
|
||||||
|
| POST | `/api/entries` | Create entry |
|
||||||
|
| PUT | `/api/entries/:id` | Update entry |
|
||||||
|
| DELETE | `/api/entries/:id` | Delete entry |
|
||||||
|
|
||||||
|
### Timesheets
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|------------------------------|----------------------|
|
||||||
|
| GET | `/api/timesheets` | Get current week |
|
||||||
|
| GET | `/api/timesheets/history` | All user timesheets |
|
||||||
|
| POST | `/api/timesheets/submit` | Submit for approval |
|
||||||
|
| GET | `/api/timesheets/:id/pdf` | Download PDF |
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|-------------------------------------|-------------------------|
|
||||||
|
| GET | `/api/admin/timesheets` | All timesheets (filter) |
|
||||||
|
| GET | `/api/admin/timesheets/:id` | Timesheet detail |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/approve` | Approve timesheet |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/reject` | Reject timesheet |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/reopen` | Reopen for editing |
|
||||||
|
| GET | `/api/admin/users` | List employees |
|
||||||
|
| POST | `/api/admin/users` | Create employee |
|
||||||
|
| GET | `/api/admin/homeowners` | List homeowners |
|
||||||
|
| POST | `/api/admin/homeowners` | Add homeowner |
|
||||||
|
| GET | `/api/admin/reports` | Reporting with filters |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Upgrading from v1
|
||||||
|
|
||||||
|
v2 is a complete rewrite. Key differences:
|
||||||
|
|
||||||
|
| Feature | v1 | v2 |
|
||||||
|
|-----------------|-----------------------------|----------------------------------|
|
||||||
|
| Storage | Browser localStorage | PostgreSQL database |
|
||||||
|
| Auth | None | JWT with roles |
|
||||||
|
| Multi-user | No | Yes — unlimited employees |
|
||||||
|
| Approval flow | No | Submit → Approve/Reject |
|
||||||
|
| PDF | Client-side (jsPDF) | Server-side (@react-pdf) |
|
||||||
|
| Email | mailto: link | SMTP with PDF attachment |
|
||||||
|
| Deploy | Static HTML | Docker Compose (one command) |
|
||||||
|
| Admin panel | No | Full admin with reporting |
|
||||||
|
| Dark mode | No | System-aware + manual toggle |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
Private — Coastal Contracting of FL. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Built with ☀️ in Florida
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ─── Database ───────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://coastal:coastal_secret@localhost:5432/coastal_timesheet
|
||||||
|
|
||||||
|
# ─── JWT Secrets ────────────────────────────────────────
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
JWT_SECRET=change-me-to-a-random-64-byte-hex-string
|
||||||
|
JWT_REFRESH_SECRET=change-me-to-a-different-random-64-byte-hex-string
|
||||||
|
|
||||||
|
# ─── Server ────────────────────────────────────────────
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ─── SMTP (Email) ──────────────────────────────────────
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
# ─── Admin ──────────────────────────────────────────────
|
||||||
|
ADMIN_EMAIL=bizzle@coastalcontracting.com
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "coastal-timesheet-backend",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Coastal Contracting Timesheet API",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"db:migrate": "npx prisma migrate deploy",
|
||||||
|
"db:push": "npx prisma db push",
|
||||||
|
"db:seed": "node prisma/seed.js",
|
||||||
|
"db:generate": "npx prisma generate",
|
||||||
|
"db:reset": "npx prisma migrate reset --force",
|
||||||
|
"setup": "npx prisma generate && npx prisma db push && node prisma/seed.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.9.0",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
employee
|
||||||
|
admin
|
||||||
|
super_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TimesheetStatus {
|
||||||
|
draft
|
||||||
|
submitted
|
||||||
|
approved
|
||||||
|
rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
role Role @default(employee)
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
refreshToken String? @map("refresh_token")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
timeEntries TimeEntry[]
|
||||||
|
timesheets Timesheet[] @relation("UserTimesheets")
|
||||||
|
approvals Timesheet[] @relation("ApprovedTimesheets")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Homeowner {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
address String?
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
timeEntries TimeEntry[]
|
||||||
|
|
||||||
|
@@map("homeowners")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TimeEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
date DateTime @db.Date
|
||||||
|
homeownerId String @map("homeowner_id")
|
||||||
|
hoursWorked Decimal @map("hours_worked") @db.Decimal(4, 2)
|
||||||
|
workDescription String @map("work_description")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
homeowner Homeowner @relation(fields: [homeownerId], references: [id])
|
||||||
|
timesheetLinks TimesheetEntry[]
|
||||||
|
|
||||||
|
@@index([userId, date])
|
||||||
|
@@index([homeownerId])
|
||||||
|
@@map("time_entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Timesheet {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
weekStart DateTime @map("week_start") @db.Date
|
||||||
|
weekEnd DateTime @map("week_end") @db.Date
|
||||||
|
status TimesheetStatus @default(draft)
|
||||||
|
submittedAt DateTime? @map("submitted_at")
|
||||||
|
approvedBy String? @map("approved_by")
|
||||||
|
approvedAt DateTime? @map("approved_at")
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation("UserTimesheets", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
approver User? @relation("ApprovedTimesheets", fields: [approvedBy], references: [id])
|
||||||
|
entries TimesheetEntry[]
|
||||||
|
|
||||||
|
@@unique([userId, weekStart])
|
||||||
|
@@index([status])
|
||||||
|
@@index([userId, weekStart])
|
||||||
|
@@map("timesheets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TimesheetEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
timesheetId String @map("timesheet_id")
|
||||||
|
timeEntryId String @map("time_entry_id")
|
||||||
|
|
||||||
|
timesheet Timesheet @relation(fields: [timesheetId], references: [id], onDelete: Cascade)
|
||||||
|
timeEntry TimeEntry @relation(fields: [timeEntryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([timesheetId, timeEntryId])
|
||||||
|
@@map("timesheet_entries")
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEFAULT_HOMEOWNERS = [
|
||||||
|
'Anderson, 217',
|
||||||
|
'Bakos',
|
||||||
|
'Beckstead, 111',
|
||||||
|
'Bentley, 310',
|
||||||
|
'Best, 103',
|
||||||
|
'Caraway, 132',
|
||||||
|
'Carmichael, M, 216',
|
||||||
|
'Casa Blanca',
|
||||||
|
'Chapin, 106',
|
||||||
|
'Conner, 309',
|
||||||
|
'Cook, 118',
|
||||||
|
'Coyle, 109',
|
||||||
|
'Davis, 114a',
|
||||||
|
'Dimmitt, 213',
|
||||||
|
'Dockery, 502',
|
||||||
|
'Fassett, 303C',
|
||||||
|
'Gypsy Wind',
|
||||||
|
'Hager, 108',
|
||||||
|
'Hanford, 308',
|
||||||
|
'Hitchcox – Clarry, 218',
|
||||||
|
'Hughes, 215',
|
||||||
|
'Kaufman 129 (Blue View)',
|
||||||
|
'Kuchman, 104',
|
||||||
|
'Lockhart, 301A',
|
||||||
|
'Lokey, 136',
|
||||||
|
'McColgan, 312',
|
||||||
|
'Mercurio, 523',
|
||||||
|
'Moff – Dean Elect',
|
||||||
|
'Rogers, 501',
|
||||||
|
'Rusten, 204A',
|
||||||
|
'Ryan, 301B',
|
||||||
|
'Salas, 144',
|
||||||
|
'Sear 128 (Twin Shores)',
|
||||||
|
'Shimp, 517',
|
||||||
|
'Sipprelle, 202',
|
||||||
|
'Trino, 131',
|
||||||
|
'Useppa Fire',
|
||||||
|
'Vogt',
|
||||||
|
'Weinsz, 141',
|
||||||
|
'Wendorf, 306',
|
||||||
|
'White (Rogan)',
|
||||||
|
'Williams, Bob, 140',
|
||||||
|
'Williams, Dan, 137B',
|
||||||
|
'Williamson-Whetstone, 102',
|
||||||
|
'Wilson, George, 516',
|
||||||
|
'Wright, 137A',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const passwordHash = await bcrypt.hash('CoastalAdmin2026!', 12);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@coastal.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@coastal.com',
|
||||||
|
name: 'Admin',
|
||||||
|
role: 'super_admin',
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Admin user created: ${admin.email}`);
|
||||||
|
|
||||||
|
// Create homeowners
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const name of DEFAULT_HOMEOWNERS) {
|
||||||
|
try {
|
||||||
|
await prisma.homeowner.upsert({
|
||||||
|
where: { name },
|
||||||
|
update: {},
|
||||||
|
create: { name },
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠️ Skipped homeowner "${name}": ${err.message}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`✅ Homeowners: ${created} created, ${skipped} skipped`);
|
||||||
|
|
||||||
|
console.log('🌱 Seed complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('❌ Seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const entriesRoutes = require('./routes/entries');
|
||||||
|
const timesheetsRoutes = require('./routes/timesheets');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const homeownersRoutes = require('./routes/homeowners');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Trust proxy (behind nginx)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
const allowedOrigins = process.env.CORS_ORIGINS
|
||||||
|
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||||
|
: ['http://localhost:5173', 'http://localhost:3000'];
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error('Not allowed by CORS'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// Handle malformed JSON errors
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err.type === 'entity.parse.failed') {
|
||||||
|
return res.status(400).json({ error: 'Invalid JSON body' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach prisma to request
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.prisma = prisma;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(503).json({ status: 'error', message: 'Database unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/entries', entriesRoutes);
|
||||||
|
app.use('/api/timesheets', timesheetsRoutes);
|
||||||
|
app.use('/api/homeowners', homeownersRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
if (err.message === 'Not allowed by CORS') {
|
||||||
|
return res.status(403).json({ error: 'CORS policy violation' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
async function shutdown(signal) {
|
||||||
|
console.log(`\n${signal} received. Shutting down gracefully...`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`🚀 Coastal Timesheet API running on port ${PORT}`);
|
||||||
|
console.log(`📋 Health check: http://localhost:${PORT}/api/health`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me';
|
||||||
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an access token (short-lived)
|
||||||
|
*/
|
||||||
|
function generateAccessToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: ACCESS_TOKEN_EXPIRY }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a refresh token (long-lived)
|
||||||
|
*/
|
||||||
|
function generateRefreshToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId: user.id, tokenType: 'refresh' },
|
||||||
|
JWT_REFRESH_SECRET,
|
||||||
|
{ expiresIn: REFRESH_TOKEN_EXPIRY }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an access token
|
||||||
|
*/
|
||||||
|
function verifyAccessToken(token) {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a refresh token
|
||||||
|
*/
|
||||||
|
function verifyRefreshToken(token) {
|
||||||
|
return jwt.verify(token, JWT_REFRESH_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication middleware — requires valid access token
|
||||||
|
*/
|
||||||
|
function authenticate(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Access token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
req.user = {
|
||||||
|
id: decoded.userId,
|
||||||
|
email: decoded.email,
|
||||||
|
role: decoded.role,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ error: 'Access token expired', code: 'TOKEN_EXPIRED' });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Invalid access token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only middleware — must be called after authenticate
|
||||||
|
*/
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admin middleware — must be called after authenticate
|
||||||
|
*/
|
||||||
|
function requireSuperAdmin(req, res, next) {
|
||||||
|
if (!req.user || req.user.role !== 'super_admin') {
|
||||||
|
return res.status(403).json({ error: 'Super admin access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateAccessToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
verifyAccessToken,
|
||||||
|
verifyRefreshToken,
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
requireSuperAdmin,
|
||||||
|
JWT_SECRET,
|
||||||
|
JWT_REFRESH_SECRET,
|
||||||
|
ACCESS_TOKEN_EXPIRY,
|
||||||
|
REFRESH_TOKEN_EXPIRY,
|
||||||
|
};
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
approveRejectSchema,
|
||||||
|
createUserSchema,
|
||||||
|
createHomeownerSchema,
|
||||||
|
updateHomeownerSchema,
|
||||||
|
reportQuerySchema,
|
||||||
|
validateBody,
|
||||||
|
validateQuery,
|
||||||
|
} = require('../utils/validation');
|
||||||
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All admin routes require authentication + admin role
|
||||||
|
router.use(authenticate, requireAdmin);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// TIMESHEETS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
|
||||||
|
router.get('/timesheets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, userId, page = '1', limit = '50' } = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
|
||||||
|
const pageNum = Math.max(1, parseInt(page, 10) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(limit, 10) || 50));
|
||||||
|
|
||||||
|
const [timesheets, total] = await Promise.all([
|
||||||
|
req.prisma.timesheet.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
approver: { select: { id: true, name: true } },
|
||||||
|
entries: {
|
||||||
|
include: {
|
||||||
|
timeEntry: {
|
||||||
|
include: { homeowner: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { submittedAt: 'desc' },
|
||||||
|
skip: (pageNum - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
req.prisma.timesheet.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timesheets: timesheets.map((ts) => {
|
||||||
|
const totalHours = ts.entries.reduce(
|
||||||
|
(sum, link) => sum + parseFloat(link.timeEntry.hoursWorked || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: ts.id,
|
||||||
|
user: ts.user,
|
||||||
|
weekStart: ts.weekStart.toISOString().split('T')[0],
|
||||||
|
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
||||||
|
status: ts.status,
|
||||||
|
submittedAt: ts.submittedAt,
|
||||||
|
approvedAt: ts.approvedAt,
|
||||||
|
approvedBy: ts.approver?.name || null,
|
||||||
|
notes: ts.notes,
|
||||||
|
totalHours,
|
||||||
|
entryCount: ts.entries.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin get timesheets error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch timesheets' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/timesheets/:id ───────────────
|
||||||
|
router.get('/timesheets/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
approver: { select: { id: true, name: true } },
|
||||||
|
entries: {
|
||||||
|
include: {
|
||||||
|
timeEntry: {
|
||||||
|
include: { homeowner: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = timesheet.entries.map((link) => ({
|
||||||
|
id: link.timeEntry.id,
|
||||||
|
date: link.timeEntry.date.toISOString().split('T')[0],
|
||||||
|
homeownerId: link.timeEntry.homeownerId,
|
||||||
|
homeownerName: link.timeEntry.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(link.timeEntry.hoursWorked),
|
||||||
|
workDescription: link.timeEntry.workDescription,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalHours = entries.reduce((sum, e) => sum + e.hoursWorked, 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: timesheet.id,
|
||||||
|
user: timesheet.user,
|
||||||
|
weekStart: timesheet.weekStart.toISOString().split('T')[0],
|
||||||
|
weekEnd: timesheet.weekEnd.toISOString().split('T')[0],
|
||||||
|
status: timesheet.status,
|
||||||
|
submittedAt: timesheet.submittedAt,
|
||||||
|
approvedAt: timesheet.approvedAt,
|
||||||
|
approvedBy: timesheet.approver?.name || null,
|
||||||
|
notes: timesheet.notes,
|
||||||
|
totalHours,
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin get timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch timesheet' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
||||||
|
router.put(
|
||||||
|
'/timesheets/:id/approve',
|
||||||
|
validateBody(approveRejectSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { notes } = req.validated;
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||||
|
if (!timesheet) {
|
||||||
|
return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheet.status !== 'submitted') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Cannot approve a timesheet with status "${timesheet.status}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await req.prisma.timesheet.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'approved',
|
||||||
|
approvedBy: req.user.id,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
notes: notes || null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: updated.id,
|
||||||
|
status: updated.status,
|
||||||
|
approvedAt: updated.approvedAt,
|
||||||
|
notes: updated.notes,
|
||||||
|
user: updated.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Approve timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to approve timesheet' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
|
||||||
|
router.put(
|
||||||
|
'/timesheets/:id/reject',
|
||||||
|
validateBody(approveRejectSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { notes } = req.validated;
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||||
|
if (!timesheet) {
|
||||||
|
return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheet.status !== 'submitted') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Cannot reject a timesheet with status "${timesheet.status}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await req.prisma.timesheet.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'rejected',
|
||||||
|
approvedBy: req.user.id,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
notes: notes || 'Rejected — please review and resubmit.',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: updated.id,
|
||||||
|
status: updated.status,
|
||||||
|
notes: updated.notes,
|
||||||
|
user: updated.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reject timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to reject timesheet' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
|
||||||
|
router.put('/timesheets/:id/reopen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||||
|
if (!timesheet) return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
|
||||||
|
if (timesheet.status === 'draft') {
|
||||||
|
return res.status(400).json({ error: 'Timesheet is already a draft' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await req.prisma.timesheet.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'draft', approvedBy: null, approvedAt: null, notes: null },
|
||||||
|
});
|
||||||
|
res.json({ id: updated.id, status: updated.status, message: 'Timesheet reopened' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reopen timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to reopen timesheet' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// USERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/users ───────────────
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await req.prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { timeEntries: true, timesheets: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
users: users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
role: u.role,
|
||||||
|
isActive: u.isActive,
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
entryCount: u._count.timeEntries,
|
||||||
|
timesheetCount: u._count.timesheets,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin get users error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch users' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/admin/users ───────────────
|
||||||
|
router.post('/users', validateBody(createUserSchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, name, role } = req.validated;
|
||||||
|
|
||||||
|
// Only super_admin can create admin/super_admin
|
||||||
|
if (
|
||||||
|
(role === 'admin' || role === 'super_admin') &&
|
||||||
|
req.user.role !== 'super_admin'
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: 'Only super admins can create admin accounts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await req.prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const user = await req.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin create user error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/admin/users/:id ───────────────
|
||||||
|
router.put('/users/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, role, isActive, password } = req.body;
|
||||||
|
|
||||||
|
const user = await req.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only super_admin can change roles to admin/super_admin
|
||||||
|
if (
|
||||||
|
role &&
|
||||||
|
(role === 'admin' || role === 'super_admin') &&
|
||||||
|
req.user.role !== 'super_admin'
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: 'Only super admins can assign admin roles' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (role !== undefined) updateData.role = role;
|
||||||
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
|
if (password) {
|
||||||
|
updateData.passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await req.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ user: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin update user error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// HOMEOWNERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/homeowners ───────────────
|
||||||
|
router.get('/homeowners', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { includeInactive } = req.query;
|
||||||
|
const where = includeInactive === 'true' ? {} : { isActive: true };
|
||||||
|
|
||||||
|
const homeowners = await req.prisma.homeowner.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
address: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { timeEntries: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
homeowners: homeowners.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
name: h.name,
|
||||||
|
address: h.address,
|
||||||
|
isActive: h.isActive,
|
||||||
|
createdAt: h.createdAt,
|
||||||
|
entryCount: h._count.timeEntries,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin get homeowners error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch homeowners' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/admin/homeowners ───────────────
|
||||||
|
router.post(
|
||||||
|
'/homeowners',
|
||||||
|
validateBody(createHomeownerSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, address } = req.validated;
|
||||||
|
|
||||||
|
const existing = await req.prisma.homeowner.findUnique({ where: { name } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: 'Homeowner with this name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeowner = await req.prisma.homeowner.create({
|
||||||
|
data: { name, address: address || null },
|
||||||
|
select: { id: true, name: true, address: true, isActive: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ homeowner });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin create homeowner error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create homeowner' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
|
||||||
|
router.put(
|
||||||
|
'/homeowners/:id',
|
||||||
|
validateBody(updateHomeownerSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, address, isActive } = req.validated;
|
||||||
|
|
||||||
|
const existing = await req.prisma.homeowner.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Homeowner not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name uniqueness if changing name
|
||||||
|
if (name && name !== existing.name) {
|
||||||
|
const nameConflict = await req.prisma.homeowner.findUnique({ where: { name } });
|
||||||
|
if (nameConflict) {
|
||||||
|
return res.status(409).json({ error: 'A homeowner with this name already exists' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (address !== undefined) updateData.address = address;
|
||||||
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
|
|
||||||
|
const updated = await req.prisma.homeowner.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
select: { id: true, name: true, address: true, isActive: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ homeowner: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin update homeowner error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update homeowner' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// REPORTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/reports ───────────────
|
||||||
|
router.get('/reports', validateQuery(reportQuerySchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to, userId, homeownerId, status } = req.validatedQuery;
|
||||||
|
|
||||||
|
// Build time entry filter
|
||||||
|
const entryWhere = {};
|
||||||
|
if (from || to) {
|
||||||
|
entryWhere.date = {};
|
||||||
|
if (from) entryWhere.date.gte = new Date(from + 'T00:00:00Z');
|
||||||
|
if (to) entryWhere.date.lte = new Date(to + 'T00:00:00Z');
|
||||||
|
}
|
||||||
|
if (userId) entryWhere.userId = userId;
|
||||||
|
if (homeownerId) entryWhere.homeownerId = homeownerId;
|
||||||
|
|
||||||
|
// Build timesheet filter
|
||||||
|
const timesheetWhere = {};
|
||||||
|
if (status) timesheetWhere.status = status;
|
||||||
|
if (userId) timesheetWhere.userId = userId;
|
||||||
|
if (from || to) {
|
||||||
|
timesheetWhere.weekStart = {};
|
||||||
|
if (from) timesheetWhere.weekStart.gte = new Date(from + 'T00:00:00Z');
|
||||||
|
if (to) timesheetWhere.weekStart.lte = new Date(to + 'T00:00:00Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entries, timesheets, userSummary, homeownerSummary] = await Promise.all([
|
||||||
|
// Raw entries
|
||||||
|
req.prisma.timeEntry.findMany({
|
||||||
|
where: entryWhere,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
homeowner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
take: 1000,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Timesheets
|
||||||
|
req.prisma.timesheet.findMany({
|
||||||
|
where: timesheetWhere,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
approver: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { weekStart: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Hours by user
|
||||||
|
req.prisma.timeEntry.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: entryWhere,
|
||||||
|
_sum: { hoursWorked: true },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Hours by homeowner
|
||||||
|
req.prisma.timeEntry.groupBy({
|
||||||
|
by: ['homeownerId'],
|
||||||
|
where: entryWhere,
|
||||||
|
_sum: { hoursWorked: true },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enrich user summary with names
|
||||||
|
const userIds = userSummary.map((u) => u.userId);
|
||||||
|
const users = await req.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const userMap = Object.fromEntries(users.map((u) => [u.id, u.name]));
|
||||||
|
|
||||||
|
// Enrich homeowner summary with names
|
||||||
|
const hoIds = homeownerSummary.map((h) => h.homeownerId);
|
||||||
|
const homeowners = await req.prisma.homeowner.findMany({
|
||||||
|
where: { id: { in: hoIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const hoMap = Object.fromEntries(homeowners.map((h) => [h.id, h.name]));
|
||||||
|
|
||||||
|
const totalHours = entries.reduce(
|
||||||
|
(sum, e) => sum + parseFloat(e.hoursWorked || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary: {
|
||||||
|
totalEntries: entries.length,
|
||||||
|
totalHours,
|
||||||
|
dateRange: {
|
||||||
|
from: from || null,
|
||||||
|
to: to || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
byUser: userSummary.map((u) => ({
|
||||||
|
userId: u.userId,
|
||||||
|
userName: userMap[u.userId] || 'Unknown',
|
||||||
|
totalHours: parseFloat(u._sum.hoursWorked || 0),
|
||||||
|
entryCount: u._count.id,
|
||||||
|
})),
|
||||||
|
byHomeowner: homeownerSummary.map((h) => ({
|
||||||
|
homeownerId: h.homeownerId,
|
||||||
|
homeownerName: hoMap[h.homeownerId] || 'Unknown',
|
||||||
|
totalHours: parseFloat(h._sum.hoursWorked || 0),
|
||||||
|
entryCount: h._count.id,
|
||||||
|
})),
|
||||||
|
timesheets: timesheets.map((ts) => ({
|
||||||
|
id: ts.id,
|
||||||
|
userName: ts.user.name,
|
||||||
|
weekStart: ts.weekStart.toISOString().split('T')[0],
|
||||||
|
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
||||||
|
status: ts.status,
|
||||||
|
submittedAt: ts.submittedAt,
|
||||||
|
approvedBy: ts.approver?.name || null,
|
||||||
|
})),
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date.toISOString().split('T')[0],
|
||||||
|
userName: e.user.name,
|
||||||
|
homeownerName: e.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(e.hoursWorked),
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin reports error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to generate report' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/admin/reports/pdf ───────────────
|
||||||
|
router.get('/reports/pdf', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, weekStart } = req.query;
|
||||||
|
|
||||||
|
if (!userId || !weekStart) {
|
||||||
|
return res.status(400).json({ error: 'userId and weekStart are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await req.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const monday = weekStart;
|
||||||
|
const sundayDate = new Date(monday + 'T00:00:00Z');
|
||||||
|
sundayDate.setUTCDate(sundayDate.getUTCDate() + 6);
|
||||||
|
const sunday = sundayDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const entries = await req.prisma.timeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date: {
|
||||||
|
gte: new Date(monday + 'T00:00:00Z'),
|
||||||
|
lte: new Date(sunday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { homeowner: { select: { name: true } } },
|
||||||
|
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: { userId_weekStart: { userId, weekStart: new Date(monday + 'T00:00:00Z') } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfBuffer = await generateTimesheetPDF({
|
||||||
|
userName: user.name,
|
||||||
|
weekStart: monday,
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
date: e.date.toISOString().split('T')[0],
|
||||||
|
homeownerName: e.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(e.hoursWorked),
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
})),
|
||||||
|
status: timesheet?.status || 'draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = buildPdfFilename(user.name, monday);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', pdfBuffer.length);
|
||||||
|
res.send(pdfBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin PDF error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to generate PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const {
|
||||||
|
generateAccessToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
verifyRefreshToken,
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
} = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
refreshSchema,
|
||||||
|
validateBody,
|
||||||
|
} = require('../utils/validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Rate limit: 5 attempts per 15 minutes on auth endpoints
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 50,
|
||||||
|
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/auth/login ───────────────
|
||||||
|
router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.validated;
|
||||||
|
|
||||||
|
const user = await req.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(403).json({ error: 'Account is deactivated. Contact your admin.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!passwordValid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(user);
|
||||||
|
const refreshToken = generateRefreshToken(user);
|
||||||
|
|
||||||
|
// Store refresh token hash in DB
|
||||||
|
await req.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/auth/register (admin only) ───────────────
|
||||||
|
router.post(
|
||||||
|
'/register',
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
validateBody(registerSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, name, role } = req.validated;
|
||||||
|
|
||||||
|
// Only super_admin can create admin/super_admin accounts
|
||||||
|
if (
|
||||||
|
(role === 'admin' || role === 'super_admin') &&
|
||||||
|
req.user.role !== 'super_admin'
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: 'Only super admins can create admin accounts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await req.prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const user = await req.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Register error:', err);
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────── POST /api/auth/refresh ───────────────
|
||||||
|
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.validated;
|
||||||
|
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = verifyRefreshToken(refreshToken);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await req.prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return res.status(401).json({ error: 'User not found or deactivated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the refresh token matches the stored one (token rotation)
|
||||||
|
if (user.refreshToken !== refreshToken) {
|
||||||
|
// Possible token theft — invalidate all tokens for this user
|
||||||
|
await req.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
return res.status(401).json({ error: 'Refresh token reuse detected. Please login again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccessToken = generateAccessToken(user);
|
||||||
|
const newRefreshToken = generateRefreshToken(user);
|
||||||
|
|
||||||
|
// Rotate refresh token
|
||||||
|
await req.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken: newRefreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
accessToken: newAccessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Refresh error:', err);
|
||||||
|
res.status(500).json({ error: 'Token refresh failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/auth/me ───────────────
|
||||||
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await req.prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Me error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/auth/logout ───────────────
|
||||||
|
router.post('/logout', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await req.prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
res.json({ message: 'Logged out successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
res.status(500).json({ error: 'Logout failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
createEntrySchema,
|
||||||
|
updateEntrySchema,
|
||||||
|
weekQuerySchema,
|
||||||
|
validateBody,
|
||||||
|
validateQuery,
|
||||||
|
} = require('../utils/validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Monday of the week containing the given date
|
||||||
|
*/
|
||||||
|
function getWeekMonday(dateStr) {
|
||||||
|
const d = dateStr ? new Date(dateStr + 'T00:00:00Z') : new Date();
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day; // Monday = 1, Sunday = 0 → go back 6
|
||||||
|
d.setUTCDate(d.getUTCDate() + diff);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekSunday(mondayStr) {
|
||||||
|
const d = new Date(mondayStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + 6);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── GET /api/entries?week=YYYY-MM-DD ───────────────
|
||||||
|
router.get('/', validateQuery(weekQuerySchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const weekParam = req.validatedQuery.week;
|
||||||
|
const monday = getWeekMonday(weekParam);
|
||||||
|
const sunday = getWeekSunday(monday);
|
||||||
|
|
||||||
|
const entries = await req.prisma.timeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
date: {
|
||||||
|
gte: new Date(monday + 'T00:00:00Z'),
|
||||||
|
lte: new Date(sunday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
homeowner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this week's timesheet is locked (submitted/approved)
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLocked = timesheet
|
||||||
|
? ['submitted', 'approved'].includes(timesheet.status)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date.toISOString().split('T')[0],
|
||||||
|
homeownerId: e.homeownerId,
|
||||||
|
homeownerName: e.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(e.hoursWorked),
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
updatedAt: e.updatedAt,
|
||||||
|
})),
|
||||||
|
weekStart: monday,
|
||||||
|
weekEnd: sunday,
|
||||||
|
isLocked,
|
||||||
|
timesheetStatus: timesheet?.status || 'draft',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get entries error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch entries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/entries ───────────────
|
||||||
|
router.post('/', validateBody(createEntrySchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, homeownerId, hoursWorked, workDescription } = req.validated;
|
||||||
|
|
||||||
|
// Check if week is locked
|
||||||
|
const monday = getWeekMonday(date);
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Cannot modify entries for a submitted or approved timesheet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify homeowner exists and is active
|
||||||
|
const homeowner = await req.prisma.homeowner.findUnique({
|
||||||
|
where: { id: homeownerId },
|
||||||
|
});
|
||||||
|
if (!homeowner || !homeowner.isActive) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await req.prisma.timeEntry.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
date: new Date(date + 'T00:00:00Z'),
|
||||||
|
homeownerId,
|
||||||
|
hoursWorked,
|
||||||
|
workDescription,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
homeowner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
entry: {
|
||||||
|
id: entry.id,
|
||||||
|
date: entry.date.toISOString().split('T')[0],
|
||||||
|
homeownerId: entry.homeownerId,
|
||||||
|
homeownerName: entry.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(entry.hoursWorked),
|
||||||
|
workDescription: entry.workDescription,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create entry error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── PUT /api/entries/:id ───────────────
|
||||||
|
router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
if (existing.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Not your entry' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if week is locked
|
||||||
|
const entryDate = existing.date.toISOString().split('T')[0];
|
||||||
|
const monday = getWeekMonday(req.validated.date || entryDate);
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Cannot modify entries for a submitted or approved timesheet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData = {};
|
||||||
|
if (req.validated.date !== undefined) {
|
||||||
|
updateData.date = new Date(req.validated.date + 'T00:00:00Z');
|
||||||
|
}
|
||||||
|
if (req.validated.homeownerId !== undefined) {
|
||||||
|
const homeowner = await req.prisma.homeowner.findUnique({
|
||||||
|
where: { id: req.validated.homeownerId },
|
||||||
|
});
|
||||||
|
if (!homeowner || !homeowner.isActive) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
||||||
|
}
|
||||||
|
updateData.homeownerId = req.validated.homeownerId;
|
||||||
|
}
|
||||||
|
if (req.validated.hoursWorked !== undefined) {
|
||||||
|
updateData.hoursWorked = req.validated.hoursWorked;
|
||||||
|
}
|
||||||
|
if (req.validated.workDescription !== undefined) {
|
||||||
|
updateData.workDescription = req.validated.workDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await req.prisma.timeEntry.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
homeowner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
entry: {
|
||||||
|
id: entry.id,
|
||||||
|
date: entry.date.toISOString().split('T')[0],
|
||||||
|
homeownerId: entry.homeownerId,
|
||||||
|
homeownerName: entry.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(entry.hoursWorked),
|
||||||
|
workDescription: entry.workDescription,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update entry error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── DELETE /api/entries/:id ───────────────
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
if (existing.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Not your entry' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if week is locked
|
||||||
|
const entryDate = existing.date.toISOString().split('T')[0];
|
||||||
|
const monday = getWeekMonday(entryDate);
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Cannot delete entries from a submitted or approved timesheet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await req.prisma.timeEntry.delete({ where: { id } });
|
||||||
|
|
||||||
|
res.json({ message: 'Entry deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete entry error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/homeowners — list active homeowners (for all authenticated users)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const homeowners = await req.prisma.homeowner.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
res.json({ homeowners });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get homeowners error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch homeowners' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
submitTimesheetSchema,
|
||||||
|
emailTimesheetSchema,
|
||||||
|
weekQuerySchema,
|
||||||
|
validateBody,
|
||||||
|
validateQuery,
|
||||||
|
} = require('../utils/validation');
|
||||||
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
|
const { sendTimesheetEmail } = require('../utils/email');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Monday of the week for a given date
|
||||||
|
*/
|
||||||
|
function getWeekMonday(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
|
d.setUTCDate(d.getUTCDate() + diff);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekSunday(mondayStr) {
|
||||||
|
const d = new Date(mondayStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + 6);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load entries + user info for a timesheet's week
|
||||||
|
*/
|
||||||
|
async function loadTimesheetData(prisma, userId, weekStart) {
|
||||||
|
const monday = typeof weekStart === 'string' ? weekStart : weekStart.toISOString().split('T')[0];
|
||||||
|
const sunday = getWeekSunday(monday);
|
||||||
|
|
||||||
|
const [user, entries, timesheet] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
}),
|
||||||
|
prisma.timeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date: {
|
||||||
|
gte: new Date(monday + 'T00:00:00Z'),
|
||||||
|
lte: new Date(sunday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { homeowner: { select: { id: true, name: true } } },
|
||||||
|
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
}),
|
||||||
|
prisma.timesheet.findUnique({
|
||||||
|
where: { userId_weekStart: { userId, weekStart: new Date(monday + 'T00:00:00Z') } },
|
||||||
|
include: {
|
||||||
|
approver: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date.toISOString().split('T')[0],
|
||||||
|
homeownerId: e.homeownerId,
|
||||||
|
homeownerName: e.homeowner.name,
|
||||||
|
hoursWorked: parseFloat(e.hoursWorked),
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
})),
|
||||||
|
timesheet,
|
||||||
|
weekStart: monday,
|
||||||
|
weekEnd: sunday,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── GET /api/timesheets?week=YYYY-MM-DD ───────────────
|
||||||
|
router.get('/', validateQuery(weekQuerySchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const weekParam = req.validatedQuery.week;
|
||||||
|
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
|
||||||
|
|
||||||
|
const data = await loadTimesheetData(req.prisma, req.user.id, monday);
|
||||||
|
|
||||||
|
const totalHours = data.entries.reduce((sum, e) => sum + e.hoursWorked, 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
weekStart: data.weekStart,
|
||||||
|
weekEnd: data.weekEnd,
|
||||||
|
status: data.timesheet?.status || 'draft',
|
||||||
|
submittedAt: data.timesheet?.submittedAt || null,
|
||||||
|
approvedAt: data.timesheet?.approvedAt || null,
|
||||||
|
approvedBy: data.timesheet?.approver?.name || null,
|
||||||
|
notes: data.timesheet?.notes || null,
|
||||||
|
timesheetId: data.timesheet?.id || null,
|
||||||
|
totalHours,
|
||||||
|
entries: data.entries,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch timesheet' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/timesheets/history ───────────────
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timesheets = await req.prisma.timesheet.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { weekStart: 'desc' },
|
||||||
|
include: {
|
||||||
|
approver: { select: { name: true } },
|
||||||
|
_count: { select: { entries: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timesheets: timesheets.map((ts) => ({
|
||||||
|
id: ts.id,
|
||||||
|
weekStart: ts.weekStart.toISOString().split('T')[0],
|
||||||
|
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
||||||
|
status: ts.status,
|
||||||
|
submittedAt: ts.submittedAt,
|
||||||
|
approvedAt: ts.approvedAt,
|
||||||
|
approvedBy: ts.approver?.name || null,
|
||||||
|
notes: ts.notes,
|
||||||
|
totalHours: null, // Could aggregate if needed
|
||||||
|
entryCount: ts._count.entries,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get history error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/timesheets/submit ───────────────
|
||||||
|
router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { weekStart } = req.validated;
|
||||||
|
const monday = getWeekMonday(weekStart);
|
||||||
|
const sunday = getWeekSunday(monday);
|
||||||
|
|
||||||
|
// Get entries for this week
|
||||||
|
const entries = await req.prisma.timeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
date: {
|
||||||
|
gte: new Date(monday + 'T00:00:00Z'),
|
||||||
|
lte: new Date(sunday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Cannot submit an empty timesheet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the timesheet
|
||||||
|
const timesheet = await req.prisma.timesheet.upsert({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'submitted',
|
||||||
|
submittedAt: new Date(),
|
||||||
|
notes: null,
|
||||||
|
approvedBy: null,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: req.user.id,
|
||||||
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
||||||
|
weekEnd: new Date(sunday + 'T00:00:00Z'),
|
||||||
|
status: 'submitted',
|
||||||
|
submittedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link entries to timesheet
|
||||||
|
// First, remove old links
|
||||||
|
await req.prisma.timesheetEntry.deleteMany({
|
||||||
|
where: { timesheetId: timesheet.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new links
|
||||||
|
await req.prisma.timesheetEntry.createMany({
|
||||||
|
data: entries.map((e) => ({
|
||||||
|
timesheetId: timesheet.id,
|
||||||
|
timeEntryId: e.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timesheetId: timesheet.id,
|
||||||
|
status: timesheet.status,
|
||||||
|
submittedAt: timesheet.submittedAt,
|
||||||
|
weekStart: monday,
|
||||||
|
weekEnd: sunday,
|
||||||
|
entryCount: entries.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Submit timesheet error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to submit timesheet' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
|
||||||
|
router.get('/:id/pdf', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner or admin can access
|
||||||
|
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
|
||||||
|
if (timesheet.userId !== req.user.id && !isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
|
||||||
|
const data = await loadTimesheetData(req.prisma, timesheet.userId, weekStart);
|
||||||
|
|
||||||
|
const pdfBuffer = await generateTimesheetPDF({
|
||||||
|
userName: data.user.name,
|
||||||
|
weekStart,
|
||||||
|
entries: data.entries,
|
||||||
|
status: timesheet.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = buildPdfFilename(data.user.name, weekStart);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', pdfBuffer.length);
|
||||||
|
res.send(pdfBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF generation error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to generate PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────── POST /api/timesheets/:id/email ───────────────
|
||||||
|
router.post('/:id/email', validateBody(emailTimesheetSchema), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { to, subject, message } = req.validated;
|
||||||
|
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner or admin
|
||||||
|
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
|
||||||
|
if (timesheet.userId !== req.user.id && !isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
|
||||||
|
const data = await loadTimesheetData(req.prisma, timesheet.userId, weekStart);
|
||||||
|
|
||||||
|
const pdfBuffer = await generateTimesheetPDF({
|
||||||
|
userName: data.user.name,
|
||||||
|
weekStart,
|
||||||
|
entries: data.entries,
|
||||||
|
status: timesheet.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfFilename = buildPdfFilename(data.user.name, weekStart);
|
||||||
|
|
||||||
|
await sendTimesheetEmail({
|
||||||
|
to,
|
||||||
|
subject: subject || `Timesheet – ${data.user.name} – Week of ${weekStart}`,
|
||||||
|
message,
|
||||||
|
pdfBuffer,
|
||||||
|
pdfFilename,
|
||||||
|
fromName: data.user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Timesheet emailed to ${to}` });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Email timesheet error:', err);
|
||||||
|
if (err.message && err.message.includes('SMTP')) {
|
||||||
|
return res.status(503).json({ error: 'Email service not configured' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to email timesheet' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
let _transporter = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the nodemailer transporter (lazy singleton)
|
||||||
|
*/
|
||||||
|
function getTransporter() {
|
||||||
|
if (_transporter) return _transporter;
|
||||||
|
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
throw new Error(
|
||||||
|
'SMTP not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: { user, pass },
|
||||||
|
tls: {
|
||||||
|
// Allow self-signed certs in dev
|
||||||
|
rejectUnauthorized: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return _transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify SMTP connection is working
|
||||||
|
*/
|
||||||
|
async function verifySmtp() {
|
||||||
|
const transporter = getTransporter();
|
||||||
|
await transporter.verify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a timesheet PDF via email
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.to - Recipient email
|
||||||
|
* @param {string} options.subject - Email subject
|
||||||
|
* @param {string} options.message - Plain-text body (optional)
|
||||||
|
* @param {Buffer} options.pdfBuffer - PDF file buffer
|
||||||
|
* @param {string} options.pdfFilename - Filename for attachment
|
||||||
|
* @param {string} options.fromName - Sender display name
|
||||||
|
* @returns {Promise<Object>} nodemailer send result
|
||||||
|
*/
|
||||||
|
async function sendTimesheetEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
pdfBuffer,
|
||||||
|
pdfFilename,
|
||||||
|
fromName,
|
||||||
|
}) {
|
||||||
|
const transporter = getTransporter();
|
||||||
|
const fromAddress = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<div style="background: #3b82f6; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 20px; letter-spacing: 1px;">
|
||||||
|
COASTAL CONTRACTING OF FL
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 24px; background: #f9fafb; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||||
|
<h2 style="color: #1f2937; margin-top: 0;">Timesheet Attached</h2>
|
||||||
|
${message ? `<p style="color: #374151; line-height: 1.6;">${escapeHtml(message)}</p>` : ''}
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
The timesheet PDF is attached to this email.
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
|
||||||
|
Sent from Coastal Timesheet • ${new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await transporter.sendMail({
|
||||||
|
from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
|
||||||
|
to,
|
||||||
|
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
||||||
|
text: message || 'Your timesheet is attached.',
|
||||||
|
html: htmlBody,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: pdfFilename || 'timesheet.pdf',
|
||||||
|
content: pdfBuffer,
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters
|
||||||
|
*/
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendTimesheetEmail,
|
||||||
|
verifySmtp,
|
||||||
|
};
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
const React = require('react');
|
||||||
|
const {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
renderToBuffer,
|
||||||
|
} = require('@react-pdf/renderer');
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 10,
|
||||||
|
paddingTop: 25,
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
headerSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1f2937',
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
},
|
||||||
|
brandLine: {
|
||||||
|
width: 60,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 5,
|
||||||
|
},
|
||||||
|
weekInfo: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 18,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#374151',
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
employeeInfo: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#374151',
|
||||||
|
backgroundColor: '#f0f9ff',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
daySection: {
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
},
|
||||||
|
dayHeader: {
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dayName: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
dayDate: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#dbeafe',
|
||||||
|
},
|
||||||
|
dayTotal: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#dbeafe',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
entryRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#f3f4f6',
|
||||||
|
minHeight: 28,
|
||||||
|
},
|
||||||
|
entryRowLast: {
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
entryRowAlternate: {
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
entryCell: {
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#374151',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
homeownerCell: {
|
||||||
|
width: '25%',
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#e5e7eb',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1f2937',
|
||||||
|
},
|
||||||
|
hoursCell: {
|
||||||
|
width: '15%',
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#e5e7eb',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#059669',
|
||||||
|
},
|
||||||
|
workDescCell: {
|
||||||
|
width: '60%',
|
||||||
|
},
|
||||||
|
summarySection: {
|
||||||
|
marginTop: 18,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: '#3b82f6',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
summaryText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1f2937',
|
||||||
|
},
|
||||||
|
totalHours: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#059669',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
fontSize: 8,
|
||||||
|
bottom: 25,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#9ca3af',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the 7 days of the week (Monday → Sunday) from a Monday date string
|
||||||
|
*/
|
||||||
|
function getWeekDays(mondayStr) {
|
||||||
|
const days = [];
|
||||||
|
const start = new Date(mondayStr + 'T00:00:00Z');
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setUTCDate(d.getUTCDate() + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
const m = date.getUTCMonth() + 1;
|
||||||
|
const d = date.getUTCDate();
|
||||||
|
const y = date.getUTCFullYear();
|
||||||
|
return `${m}/${d}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekRange(weekDays) {
|
||||||
|
if (!weekDays.length) return '';
|
||||||
|
const first = weekDays[0];
|
||||||
|
const last = weekDays[weekDays.length - 1];
|
||||||
|
const opts = { month: 'short', day: 'numeric' };
|
||||||
|
const startStr = first.toLocaleDateString('en-US', { ...opts, timeZone: 'UTC' });
|
||||||
|
const endStr = last.toLocaleDateString('en-US', { ...opts, year: 'numeric', timeZone: 'UTC' });
|
||||||
|
return `${startStr} – ${endStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved': return { bg: '#dcfce7', text: '#166534' };
|
||||||
|
case 'submitted': return { bg: '#dbeafe', text: '#1e40af' };
|
||||||
|
case 'rejected': return { bg: '#fef2f2', text: '#991b1b' };
|
||||||
|
default: return { bg: '#f3f4f6', text: '#374151' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the React-PDF document element
|
||||||
|
*/
|
||||||
|
function buildTimesheetDocument({ userName, weekStart, entries, status }) {
|
||||||
|
const weekDays = getWeekDays(weekStart);
|
||||||
|
|
||||||
|
// Group entries by date
|
||||||
|
const entriesByDate = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dateKey = typeof entry.date === 'string'
|
||||||
|
? entry.date
|
||||||
|
: entry.date.toISOString().split('T')[0];
|
||||||
|
if (!entriesByDate[dateKey]) entriesByDate[dateKey] = [];
|
||||||
|
entriesByDate[dateKey].push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total hours
|
||||||
|
const totalHours = entries.reduce(
|
||||||
|
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusColors = getStatusColor(status);
|
||||||
|
|
||||||
|
const el = React.createElement;
|
||||||
|
|
||||||
|
return el(Document, null,
|
||||||
|
el(Page, { size: 'A4', style: styles.page },
|
||||||
|
// Header
|
||||||
|
el(View, { style: styles.headerSection },
|
||||||
|
el(View, { style: styles.brandLine }),
|
||||||
|
el(Text, { style: styles.header }, 'COASTAL CONTRACTING OF FL')
|
||||||
|
),
|
||||||
|
|
||||||
|
// Week range
|
||||||
|
el(View, { style: styles.weekInfo },
|
||||||
|
el(Text, null, `Week of: ${formatWeekRange(weekDays)}`)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Employee name
|
||||||
|
userName
|
||||||
|
? el(View, { style: styles.employeeInfo },
|
||||||
|
el(Text, null, `Employee: ${userName}`)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
status && status !== 'draft'
|
||||||
|
? el(View, {
|
||||||
|
style: {
|
||||||
|
...styles.statusBadge,
|
||||||
|
backgroundColor: statusColors.bg,
|
||||||
|
color: statusColors.text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
el(Text, {
|
||||||
|
style: { color: statusColors.text },
|
||||||
|
}, `Status: ${status.charAt(0).toUpperCase() + status.slice(1)}`)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// Days
|
||||||
|
...weekDays.map((day, dayIndex) => {
|
||||||
|
const dayKey = day.toISOString().split('T')[0];
|
||||||
|
const dayEntries = entriesByDate[dayKey] || [];
|
||||||
|
|
||||||
|
if (dayEntries.length === 0) return null;
|
||||||
|
|
||||||
|
const dayTotal = dayEntries.reduce(
|
||||||
|
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return el(View, { key: dayIndex, style: styles.daySection },
|
||||||
|
// Day header
|
||||||
|
el(View, { style: styles.dayHeader },
|
||||||
|
el(View, null,
|
||||||
|
el(Text, { style: styles.dayName }, DAY_NAMES[day.getUTCDay()]),
|
||||||
|
el(Text, { style: styles.dayDate }, formatDate(day))
|
||||||
|
),
|
||||||
|
el(Text, { style: styles.dayTotal }, `${dayTotal.toFixed(1)} hours`)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Entries
|
||||||
|
...dayEntries.map((entry, entryIndex) => {
|
||||||
|
const isLast = entryIndex === dayEntries.length - 1;
|
||||||
|
const isAlt = entryIndex % 2 === 1;
|
||||||
|
const rowStyle = [
|
||||||
|
styles.entryRow,
|
||||||
|
isLast ? styles.entryRowLast : null,
|
||||||
|
isAlt ? styles.entryRowAlternate : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return el(View, { key: entryIndex, style: rowStyle },
|
||||||
|
el(View, { style: [styles.entryCell, styles.homeownerCell] },
|
||||||
|
el(Text, null, entry.homeownerName || entry.homeowner || '-')
|
||||||
|
),
|
||||||
|
el(View, { style: [styles.entryCell, styles.hoursCell] },
|
||||||
|
el(Text, null, String(entry.hoursWorked || '0'))
|
||||||
|
),
|
||||||
|
el(View, { style: [styles.entryCell, styles.workDescCell] },
|
||||||
|
el(Text, null, entry.workDescription || '-')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).filter(Boolean),
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
el(View, { style: styles.summarySection },
|
||||||
|
el(Text, { style: styles.summaryText }, 'Weekly Total'),
|
||||||
|
el(Text, { style: styles.totalHours }, `${totalHours.toFixed(1)} Hours`)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
el(Text, { style: styles.footer },
|
||||||
|
`Generated on ${new Date().toLocaleDateString()} • Coastal Contracting of FL`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a timesheet PDF buffer
|
||||||
|
* @param {Object} data - { userName, weekStart, entries, status }
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async function generateTimesheetPDF(data) {
|
||||||
|
const doc = buildTimesheetDocument(data);
|
||||||
|
const buffer = await renderToBuffer(doc);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a filename for the PDF
|
||||||
|
*/
|
||||||
|
function buildPdfFilename(userName, weekStart) {
|
||||||
|
const safeName = (userName || 'timesheet')
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.toLowerCase();
|
||||||
|
return `timesheet_${safeName}_${weekStart}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateTimesheetPDF,
|
||||||
|
buildPdfFilename,
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const { z } = require('zod');
|
||||||
|
|
||||||
|
// ──────────────────────────── Auth ────────────────────────────
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address').max(255),
|
||||||
|
password: z.string().min(1, 'Password is required').max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address').max(255),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.max(128),
|
||||||
|
name: z.string().min(1, 'Name is required').max(100).trim(),
|
||||||
|
role: z.enum(['employee', 'admin', 'super_admin']).default('employee'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1, 'Refresh token is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────── Entries ────────────────────────────
|
||||||
|
|
||||||
|
const createEntrySchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||||
|
homeownerId: z.string().uuid('Invalid homeowner ID'),
|
||||||
|
hoursWorked: z
|
||||||
|
.number()
|
||||||
|
.positive('Hours must be positive')
|
||||||
|
.max(24, 'Hours cannot exceed 24'),
|
||||||
|
workDescription: z.string().min(1, 'Description is required').max(1000).trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEntrySchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||||
|
homeownerId: z.string().uuid('Invalid homeowner ID').optional(),
|
||||||
|
hoursWorked: z
|
||||||
|
.number()
|
||||||
|
.positive('Hours must be positive')
|
||||||
|
.max(24, 'Hours cannot exceed 24')
|
||||||
|
.optional(),
|
||||||
|
workDescription: z.string().min(1).max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const weekQuerySchema = z.object({
|
||||||
|
week: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Week must be YYYY-MM-DD (Monday)')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────── Timesheets ────────────────────────────
|
||||||
|
|
||||||
|
const submitTimesheetSchema = z.object({
|
||||||
|
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailTimesheetSchema = z.object({
|
||||||
|
to: z.string().email('Invalid recipient email').max(255),
|
||||||
|
subject: z.string().max(200).optional(),
|
||||||
|
message: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────── Admin ────────────────────────────
|
||||||
|
|
||||||
|
const approveRejectSchema = z.object({
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserSchema = registerSchema;
|
||||||
|
|
||||||
|
const createHomeownerSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(200).trim(),
|
||||||
|
address: z.string().max(500).trim().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateHomeownerSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim().optional(),
|
||||||
|
address: z.string().max(500).trim().optional().nullable(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportQuerySchema = z.object({
|
||||||
|
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be YYYY-MM-DD').optional(),
|
||||||
|
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be YYYY-MM-DD').optional(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
homeownerId: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────── Helpers ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware factory for validating request body with a zod schema
|
||||||
|
*/
|
||||||
|
function validateBody(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const result = schema.safeParse(req.body);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.errors.map((e) => ({
|
||||||
|
field: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
}));
|
||||||
|
return res.status(400).json({ error: 'Validation failed', details: errors });
|
||||||
|
}
|
||||||
|
req.validated = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware factory for validating query parameters
|
||||||
|
*/
|
||||||
|
function validateQuery(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const result = schema.safeParse(req.query);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.errors.map((e) => ({
|
||||||
|
field: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
}));
|
||||||
|
return res.status(400).json({ error: 'Invalid query parameters', details: errors });
|
||||||
|
}
|
||||||
|
req.validatedQuery = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
refreshSchema,
|
||||||
|
createEntrySchema,
|
||||||
|
updateEntrySchema,
|
||||||
|
weekQuerySchema,
|
||||||
|
submitTimesheetSchema,
|
||||||
|
emailTimesheetSchema,
|
||||||
|
approveRejectSchema,
|
||||||
|
createUserSchema,
|
||||||
|
createHomeownerSchema,
|
||||||
|
updateHomeownerSchema,
|
||||||
|
reportQuerySchema,
|
||||||
|
validateBody,
|
||||||
|
validateQuery,
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
coastal-timesheet-dev:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.dev
|
|
||||||
container_name: coastal-timesheet-dev
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- /app/node_modules
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
networks:
|
|
||||||
- timesheet-dev-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
timesheet-dev-network:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
coastal-timesheet:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: coastal-timesheet-app
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- timesheet-network
|
|
||||||
|
|
||||||
# Optional: Add a reverse proxy with SSL if needed
|
|
||||||
# nginx-proxy:
|
|
||||||
# image: nginx:alpine
|
|
||||||
# container_name: coastal-timesheet-proxy
|
|
||||||
# ports:
|
|
||||||
# - "80:80"
|
|
||||||
# - "443:443"
|
|
||||||
# volumes:
|
|
||||||
# - ./proxy.conf:/etc/nginx/nginx.conf
|
|
||||||
# - ./ssl:/etc/nginx/ssl
|
|
||||||
# depends_on:
|
|
||||||
# - coastal-timesheet
|
|
||||||
# networks:
|
|
||||||
# - timesheet-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
timesheet-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
# Optional: Add volumes for persistent storage if needed in future
|
|
||||||
# volumes:
|
|
||||||
# timesheet-data:
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ───────────────────────────────────────────────────
|
||||||
|
# Coastal Timesheet — Backend Dockerfile
|
||||||
|
# Node 22 Alpine · Prisma · Express
|
||||||
|
# ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Stage 1: Install dependencies ──────────────────
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY backend/package.json backend/package-lock.json* ./
|
||||||
|
|
||||||
|
# Install production dependencies
|
||||||
|
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
|
||||||
|
|
||||||
|
# ── Stage 2: Build (generate Prisma client) ────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy deps from previous stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# ── Stage 3: Production image ─────────────────────
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Add non-root user for security
|
||||||
|
RUN addgroup --system --gid 1001 coastal && \
|
||||||
|
adduser --system --uid 1001 coastal
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER coastal
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
# Start with migration and seed on first run
|
||||||
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── PostgreSQL ─────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: coastal-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: coastal
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret}
|
||||||
|
POSTGRES_DB: coastal_timesheet
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:5432:5432'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- coastal
|
||||||
|
|
||||||
|
# ─── Backend API ────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
container_name: coastal-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026}
|
||||||
|
PORT: '3001'
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://100.94.106.120:8080}
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:3001:3001'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- coastal
|
||||||
|
|
||||||
|
# ─── Frontend (Nginx) ──────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: docker/frontend/Dockerfile
|
||||||
|
container_name: coastal-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- '100.94.106.120:8080:80'
|
||||||
|
volumes:
|
||||||
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- coastal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
coastal:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# ───────────────────────────────────────────────────
|
||||||
|
# Coastal Timesheet — Frontend Dockerfile
|
||||||
|
# Build: Node 22 Alpine → Serve: Nginx Alpine
|
||||||
|
# ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Stage 1: Install dependencies ──────────────────
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files (frontend lives in the frontend/ directory)
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
|
||||||
|
# Install all dependencies (including devDependencies for build)
|
||||||
|
RUN npm ci 2>/dev/null || npm install
|
||||||
|
|
||||||
|
# ── Stage 2: Build ────────────────────────────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Set API URL for production build
|
||||||
|
ARG VITE_API_URL=/api
|
||||||
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
|
||||||
|
# Build the React app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 3: Serve with Nginx ─────────────────────
|
||||||
|
FROM nginx:1.27-alpine AS runner
|
||||||
|
|
||||||
|
# Remove default nginx config and static files
|
||||||
|
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# Copy built frontend assets
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# The nginx config is mounted via docker-compose volume
|
||||||
|
# but we include a fallback in case it's run standalone
|
||||||
|
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
# Nginx runs as non-root by default in alpine image
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Coastal Timesheet — Nginx reverse proxy
|
||||||
|
# /api/* → backend:3001
|
||||||
|
# /* → frontend static files
|
||||||
|
|
||||||
|
upstream backend_api {
|
||||||
|
server backend:3001;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml
|
||||||
|
application/rss+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Client body size (for file uploads if ever needed)
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# ─── API proxy ───────────────────────────────────────
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# Timeouts for PDF generation / email sending
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Frontend static files ───────────────────────────
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback — serve index.html for client-side routes
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Cache static assets aggressively
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Health check endpoint for load balancer ─────────
|
||||||
|
location = /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
return 200 'ok';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌊</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" />
|
||||||
|
<meta name="theme-color" content="#0ea5e9" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<title>Coastal Timesheet</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 dark:bg-gray-950 antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "coastal-timesheet-v2",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ export default {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Token helpers ─── */
|
||||||
|
|
||||||
|
function getAccessToken() {
|
||||||
|
return localStorage.getItem('accessToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken() {
|
||||||
|
return localStorage.getItem('refreshToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTokens(accessToken, refreshToken) {
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem('refreshToken', refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens() {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Request interceptor: attach access token ─── */
|
||||||
|
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ─── Response interceptor: auto-refresh on 401 ─── */
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue = [];
|
||||||
|
|
||||||
|
function processQueue(error, token) {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
/* If 401 with TOKEN_EXPIRED and we haven't retried yet */
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
error.response?.data?.code === 'TOKEN_EXPIRED' &&
|
||||||
|
!originalRequest._retry
|
||||||
|
) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
/* Queue this request until the refresh completes */
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Call refresh endpoint directly (bypass interceptor) */
|
||||||
|
const { data } = await axios.post('/api/auth/refresh', {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
|
processQueue(null, data.accessToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
clearTokens();
|
||||||
|
/* Redirect to login */
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If 401 on non-refresh, clear and redirect */
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
!originalRequest.url?.includes('/auth/refresh')
|
||||||
|
) {
|
||||||
|
clearTokens();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken };
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, Plus } from 'lucide-react';
|
||||||
|
import EntryForm from './EntryForm';
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) {
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
// Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift)
|
||||||
|
const [y, m, dy] = date.split('-').map(Number);
|
||||||
|
const d = new Date(y, m - 1, dy);
|
||||||
|
const isToday = new Date().toLocaleDateString('en-CA') === date;
|
||||||
|
const dayName = DAY_NAMES[d.getDay()];
|
||||||
|
const shortDay = SHORT_DAYS[d.getDay()];
|
||||||
|
const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
const totalHours = (entries || []).reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
|
||||||
|
const entryCount = (entries || []).filter((e) => e.homeownerId || e.hoursWorked || e.workDescription).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border transition-all ${
|
||||||
|
isToday
|
||||||
|
? 'border-sky-200 dark:border-sky-500/30 bg-sky-50/30 dark:bg-sky-500/5 shadow-sm shadow-sky-100 dark:shadow-none'
|
||||||
|
: 'border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900/50'
|
||||||
|
}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-sm font-bold ${
|
||||||
|
isToday
|
||||||
|
? 'bg-sky-500 text-white shadow-md shadow-sky-500/30'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{shortDay}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{dayName}</span>
|
||||||
|
{isToday && <span className="text-[10px] font-bold text-sky-500 bg-sky-100 dark:bg-sky-500/20 px-1.5 py-0.5 rounded-md">TODAY</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{dateLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{totalHours > 0 && (
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{totalHours.toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entryCount > 0 && (
|
||||||
|
<span className="text-xs bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400 px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{entryCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown size={18} className={`text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4 space-y-3">
|
||||||
|
{(entries || []).map((entry) => (
|
||||||
|
<EntryForm
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
homeowners={homeowners}
|
||||||
|
onChange={onEntryChange}
|
||||||
|
onDelete={onDeleteEntry}
|
||||||
|
canDelete={(entries || []).length > 1}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAddEntry(date)}
|
||||||
|
className="w-full py-2.5 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 text-gray-400 hover:text-sky-500 hover:border-sky-300 dark:hover:border-sky-500/40 text-sm font-medium flex items-center justify-center gap-1.5 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Entry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import HomeownerSelect from './HomeownerSelect';
|
||||||
|
|
||||||
|
export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) {
|
||||||
|
function handleChange(field, value) {
|
||||||
|
onChange(entry.id, { ...entry, [field]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInput = entry.homeownerId || entry.hoursWorked || entry.workDescription;
|
||||||
|
const isComplete = entry.homeownerId && entry.hoursWorked && entry.workDescription;
|
||||||
|
const isPartial = hasInput && !isComplete;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-3 space-y-3 transition-all ${
|
||||||
|
isPartial
|
||||||
|
? 'border-amber-300 dark:border-amber-500/40 bg-amber-50/50 dark:bg-amber-500/5'
|
||||||
|
: 'border-gray-200 dark:border-gray-700/60 bg-white dark:bg-gray-800/50'
|
||||||
|
} ${disabled ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||||
|
{/* Homeowner */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Homeowner {isPartial && !entry.homeownerId && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<HomeownerSelect
|
||||||
|
homeowners={homeowners}
|
||||||
|
value={entry.homeownerId || ''}
|
||||||
|
onChange={(v) => handleChange('homeownerId', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours + Delete */}
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Hours {isPartial && !entry.hoursWorked && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.25"
|
||||||
|
min="0"
|
||||||
|
max="24"
|
||||||
|
value={entry.hoursWorked || ''}
|
||||||
|
onChange={(e) => handleChange('hoursWorked', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canDelete && !disabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
className="p-2.5 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
||||||
|
title="Remove entry"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Work Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Work Description {isPartial && !entry.workDescription && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={entry.workDescription || ''}
|
||||||
|
onChange={(e) => handleChange('workDescription', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Describe work performed..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm resize-none focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChevronDown, Plus, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function HomeownerSelect({ homeowners, value, onChange, disabled }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && inputRef.current) inputRef.current.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = homeowners.filter((h) =>
|
||||||
|
h.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLabel = homeowners.find((h) => h.id === value)?.name || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl border text-left text-sm transition-all ${
|
||||||
|
disabled
|
||||||
|
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||||
|
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white hover:border-sky-400 focus:ring-2 focus:ring-sky-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={selectedLabel ? '' : 'text-gray-400'}>{selectedLabel || 'Select homeowner...'}</span>
|
||||||
|
<ChevronDown size={16} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl shadow-gray-200/50 dark:shadow-black/40 max-h-64 overflow-hidden">
|
||||||
|
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border-none text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto max-h-48 p-1">
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
|
||||||
|
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
h.id === value
|
||||||
|
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && search && !adding && (
|
||||||
|
<div className="px-3 py-4 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-2">No match found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
import { Calendar, Clock, Shield, LogOut, Menu, X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout, isAdmin } = useAuth();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: Clock, label: 'Timesheet' },
|
||||||
|
{ to: '/history', icon: Calendar, label: 'History' },
|
||||||
|
...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200/60 dark:border-gray-800/60">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg font-semibold bg-gradient-to-r from-sky-500 to-teal-400 bg-clip-text text-transparent">
|
||||||
|
Coastal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Nav */}
|
||||||
|
<nav className="hidden sm:flex items-center gap-1">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="hidden sm:flex items-center gap-2 pl-2 border-l border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 max-w-[120px] truncate">{user?.name}</span>
|
||||||
|
<button onClick={logout} className="p-2 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all" title="Logout">
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setMenuOpen(!menuOpen)} className="sm:hidden p-2 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="sm:hidden border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === '/'}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-800 mt-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{user?.name} ({user?.role})</span>
|
||||||
|
<button onClick={logout} className="flex items-center gap-2 text-red-500 text-sm font-medium">
|
||||||
|
<LogOut size={16} /> Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Nav */}
|
||||||
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-200/60 dark:border-gray-800/60 safe-area-bottom">
|
||||||
|
<div className="flex justify-around py-2">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex flex-col items-center gap-0.5 px-4 py-1.5 rounded-xl transition-all min-w-[64px] ${
|
||||||
|
isActive
|
||||||
|
? 'text-sky-500 dark:text-sky-400'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={22} />
|
||||||
|
<span className="text-[10px] font-medium">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-6 pb-24 sm:pb-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Check, Loader2, AlertCircle, CloudOff } from 'lucide-react';
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
idle: {
|
||||||
|
icon: null,
|
||||||
|
text: '',
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
icon: Loader2,
|
||||||
|
text: 'Saving…',
|
||||||
|
color: 'text-ocean-500',
|
||||||
|
animate: 'animate-spin',
|
||||||
|
},
|
||||||
|
saved: {
|
||||||
|
icon: Check,
|
||||||
|
text: 'Saved',
|
||||||
|
color: 'text-emerald-500',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
text: 'Save failed',
|
||||||
|
color: 'text-red-500',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
icon: CloudOff,
|
||||||
|
text: 'Offline',
|
||||||
|
color: 'text-amber-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SaveIndicator({ status = 'idle' }) {
|
||||||
|
const config = STATUS_MAP[status] || STATUS_MAP.idle;
|
||||||
|
|
||||||
|
if (!config.icon) return null;
|
||||||
|
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs font-medium ${config.color} transition-opacity duration-300`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Icon size={14} className={config.animate || ''} />
|
||||||
|
<span>{config.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
FileEdit,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
draft: {
|
||||||
|
label: 'Draft',
|
||||||
|
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||||
|
text: 'text-gray-600 dark:text-gray-400',
|
||||||
|
icon: FileEdit,
|
||||||
|
},
|
||||||
|
submitted: {
|
||||||
|
label: 'Submitted',
|
||||||
|
bg: 'bg-ocean-50 dark:bg-ocean-950',
|
||||||
|
text: 'text-ocean-600 dark:text-ocean-400',
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
label: 'Approved',
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-950',
|
||||||
|
text: 'text-emerald-600 dark:text-emerald-400',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: 'Rejected',
|
||||||
|
bg: 'bg-red-50 dark:bg-red-950',
|
||||||
|
text: 'text-red-600 dark:text-red-400',
|
||||||
|
icon: XCircle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, size = 'md' }) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.draft;
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'px-2 py-0.5 text-[10px] gap-1'
|
||||||
|
: 'px-3 py-1 text-xs gap-1.5';
|
||||||
|
|
||||||
|
const iconSize = size === 'sm' ? 10 : 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`badge ${config.bg} ${config.text} ${sizeClasses}`}
|
||||||
|
>
|
||||||
|
<Icon size={iconSize} />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { isDark, toggle } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="btn-ghost relative w-10 h-10 p-0 rounded-full"
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||||
|
isDark ? 'opacity-0 rotate-90 scale-50' : 'opacity-100 rotate-0 scale-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sun size={18} className="text-amber-500" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||||
|
isDark ? 'opacity-100 rotate-0 scale-100' : 'opacity-0 -rotate-90 scale-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Moon size={18} className="text-ocean-300" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
|
||||||
|
|
||||||
|
function getWeekRange(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay();
|
||||||
|
const start = new Date(d);
|
||||||
|
start.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); // Monday
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 6); // Sunday
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentWeek(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const { start, end } = getWeekRange(date);
|
||||||
|
return now >= start && now <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeekNavigator({ selectedDate, onDateChange, onSwipeHandlers }) {
|
||||||
|
const { start, end } = getWeekRange(selectedDate);
|
||||||
|
|
||||||
|
function prevWeek() {
|
||||||
|
const d = new Date(selectedDate);
|
||||||
|
d.setDate(d.getDate() - 7);
|
||||||
|
onDateChange(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextWeek() {
|
||||||
|
const d = new Date(selectedDate);
|
||||||
|
d.setDate(d.getDate() + 7);
|
||||||
|
onDateChange(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
onDateChange(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = isCurrentWeek(selectedDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between" {...(onSwipeHandlers || {})}>
|
||||||
|
<button
|
||||||
|
onClick={prevWeek}
|
||||||
|
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||||
|
aria-label="Previous week"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{formatDate(start)} — {formatDate(end)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{start.getFullYear()}
|
||||||
|
{current && <span className="ml-2 text-sky-500 font-medium">This Week</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!current && (
|
||||||
|
<button
|
||||||
|
onClick={goToday}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 text-xs font-medium hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<CalendarDays size={14} />
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextWeek}
|
||||||
|
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||||
|
aria-label="Next week"
|
||||||
|
>
|
||||||
|
<ChevronRight size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getWeekRange };
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('user');
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
/* On mount, verify the stored token is still valid */
|
||||||
|
useEffect(() => {
|
||||||
|
async function verify() {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/auth/me');
|
||||||
|
setUser(data.user);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
} catch {
|
||||||
|
clearTokens();
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verify();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
|
setUser(data.user);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
return data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} catch {
|
||||||
|
/* ignore — we clear locally regardless */
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAdmin,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-save hook with debouncing and visual status.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* saveStatus — 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
* triggerSave — call with an async fn that does the actual save
|
||||||
|
* resetStatus — manually reset to idle
|
||||||
|
*/
|
||||||
|
export function useAutoSave(debounceMs = 500) {
|
||||||
|
const [saveStatus, setSaveStatus] = useState('idle');
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const savedTimerRef = useRef(null);
|
||||||
|
|
||||||
|
/* Clean up on unmount */
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerSave = useCallback(
|
||||||
|
(saveFn) => {
|
||||||
|
/* Clear any pending debounce */
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||||
|
|
||||||
|
setSaveStatus('saving');
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await saveFn();
|
||||||
|
setSaveStatus('saved');
|
||||||
|
/* Revert to idle after 3 seconds */
|
||||||
|
savedTimerRef.current = setTimeout(() => {
|
||||||
|
setSaveStatus('idle');
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auto-save error:', err);
|
||||||
|
setSaveStatus('error');
|
||||||
|
/* Revert to idle after 5 seconds */
|
||||||
|
savedTimerRef.current = setTimeout(() => {
|
||||||
|
setSaveStatus('idle');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
},
|
||||||
|
[debounceMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetStatus = useCallback(() => setSaveStatus('idle'), []);
|
||||||
|
|
||||||
|
return { saveStatus, triggerSave, resetStatus };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple swipe detection hook for mobile week navigation.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { onTouchStart, onTouchEnd } = useSwipe({ onSwipeLeft, onSwipeRight });
|
||||||
|
* <div onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
|
||||||
|
*/
|
||||||
|
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 50 }) {
|
||||||
|
const touchStartX = useRef(0);
|
||||||
|
const touchStartY = useRef(0);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e) => {
|
||||||
|
touchStartX.current = e.changedTouches[0].clientX;
|
||||||
|
touchStartY.current = e.changedTouches[0].clientY;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
|
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
||||||
|
|
||||||
|
/* Only trigger if horizontal swipe is dominant */
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
|
||||||
|
if (deltaX < 0 && onSwipeLeft) {
|
||||||
|
onSwipeLeft();
|
||||||
|
} else if (deltaX > 0 && onSwipeRight) {
|
||||||
|
onSwipeRight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSwipeLeft, onSwipeRight, threshold]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { onTouchStart, onTouchEnd };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [isDark, setIsDark] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
if (stored) return stored === 'dark';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
/* Listen for system theme changes when no preference is stored */
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
function handleChange(e) {
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
if (!stored) {
|
||||||
|
setIsDark(e.matches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handleChange);
|
||||||
|
return () => mq.removeEventListener('change', handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setIsDark((prev) => !prev), []);
|
||||||
|
|
||||||
|
return { isDark, toggle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Apple-like Design System ── */
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
font-feature-settings: 'kern' 1, 'liga' 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area padding for notched phones */
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default focus outlines, add our own */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
@apply ring-2 ring-ocean-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scrolling containers */
|
||||||
|
.scroll-container {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Glass morphism card */
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl;
|
||||||
|
@apply border border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
@apply rounded-2xl shadow-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elevated card */
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-900;
|
||||||
|
@apply border border-gray-200 dark:border-gray-800;
|
||||||
|
@apply rounded-2xl shadow-soft;
|
||||||
|
@apply transition-shadow duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
@apply shadow-soft-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style input */
|
||||||
|
.input-field {
|
||||||
|
@apply w-full px-4 py-3;
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800;
|
||||||
|
@apply border border-transparent;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
@apply placeholder-gray-400 dark:placeholder-gray-500;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply text-base;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
@apply bg-white dark:bg-gray-700;
|
||||||
|
@apply border-ocean-500;
|
||||||
|
@apply ring-2 ring-ocean-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary button */
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply px-6 py-3;
|
||||||
|
@apply bg-ocean-500 hover:bg-ocean-600 active:bg-ocean-700;
|
||||||
|
@apply text-white font-semibold;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply shadow-sm hover:shadow-md active:shadow-sm;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
@apply bg-ocean-300 dark:bg-ocean-800 cursor-not-allowed shadow-none;
|
||||||
|
@apply active:scale-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary button */
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply px-6 py-3;
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700;
|
||||||
|
@apply text-gray-700 dark:text-gray-300 font-semibold;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success button (for submit) */
|
||||||
|
.btn-success {
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply px-6 py-4;
|
||||||
|
@apply bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700;
|
||||||
|
@apply text-white font-bold text-lg;
|
||||||
|
@apply rounded-2xl;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply shadow-lg shadow-emerald-500/25 hover:shadow-xl hover:shadow-emerald-500/30;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:disabled {
|
||||||
|
@apply bg-emerald-300 dark:bg-emerald-800 cursor-not-allowed shadow-none;
|
||||||
|
@apply active:scale-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger button */
|
||||||
|
.btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply px-6 py-3;
|
||||||
|
@apply bg-red-500 hover:bg-red-600 active:bg-red-700;
|
||||||
|
@apply text-white font-semibold;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost button */
|
||||||
|
.btn-ghost {
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply px-4 py-2;
|
||||||
|
@apply text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100;
|
||||||
|
@apply hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge base */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center;
|
||||||
|
@apply px-3 py-1;
|
||||||
|
@apply text-xs font-semibold uppercase tracking-wider;
|
||||||
|
@apply rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.section-title {
|
||||||
|
@apply text-xs font-semibold uppercase tracking-wider;
|
||||||
|
@apply text-gray-400 dark:text-gray-500;
|
||||||
|
@apply mb-2 px-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly sizing */
|
||||||
|
.touch-target {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transitions for route changes ── */
|
||||||
|
.page-enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom scrollbar for desktop ── */
|
||||||
|
@media (hover: hover) {
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 dark:bg-gray-700;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400 dark:bg-gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton loading ── */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme('colors.gray.200') 25%,
|
||||||
|
theme('colors.gray.100') 50%,
|
||||||
|
theme('colors.gray.200') 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme('colors.gray.800') 25%,
|
||||||
|
theme('colors.gray.700') 50%,
|
||||||
|
theme('colors.gray.800') 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Timesheet from './pages/Timesheet';
|
||||||
|
import History from './pages/History';
|
||||||
|
import Admin from './pages/Admin';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-screen"><div className="animate-spin w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full" /></div>;
|
||||||
|
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminRoute({ children }) {
|
||||||
|
const { isAdmin, loading } = useAuth();
|
||||||
|
if (loading) return null;
|
||||||
|
return isAdmin ? children : <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Timesheet />} />
|
||||||
|
<Route path="history" element={<History />} />
|
||||||
|
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="bg-sky-500 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingReviews() {
|
||||||
|
const [timesheets, setTimesheets] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionId, setActionId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPending();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadPending() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/timesheets?status=submitted');
|
||||||
|
setTimesheets(res.data.timesheets || res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(id) {
|
||||||
|
setActionId(id);
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/timesheets/${id}/approve`);
|
||||||
|
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to approve');
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(id) {
|
||||||
|
const notes = prompt('Rejection reason (optional):');
|
||||||
|
if (notes === null) return;
|
||||||
|
setActionId(id);
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/timesheets/${id}/reject`, { notes });
|
||||||
|
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to reject');
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||||
|
|
||||||
|
if (timesheets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<Check size={40} className="mx-auto text-emerald-400 mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">All caught up! No pending reviews.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{timesheets.map((ts) => {
|
||||||
|
const start = new Date(ts.weekStart);
|
||||||
|
const end = new Date(ts.weekEnd);
|
||||||
|
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
return (
|
||||||
|
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => approve(ts.id)}
|
||||||
|
disabled={actionId === ts.id}
|
||||||
|
className="p-2.5 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 transition-all"
|
||||||
|
title="Approve"
|
||||||
|
>
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => reject(ts.id)}
|
||||||
|
disabled={actionId === ts.id}
|
||||||
|
className="p-2.5 rounded-xl bg-red-50 dark:bg-red-500/10 text-red-500 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-500/20 transition-all"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageUsers() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'employee' });
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/users');
|
||||||
|
setUsers(res.data.users || res.data || []);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await api.post('/admin/users', form);
|
||||||
|
setForm({ name: '', email: '', password: '', role: 'employee' });
|
||||||
|
setShowForm(false);
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to create user');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 text-sm font-medium hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<UserPlus size={16} /> Add Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={createUser} className="bg-sky-50/50 dark:bg-sky-500/5 rounded-2xl border border-sky-200 dark:border-sky-500/20 p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Full name" className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||||
|
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required type="email" placeholder="Email" className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||||
|
<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required type="password" placeholder="Password (min 8 chars)" minLength={8} className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||||
|
<option value="employee">Employee</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||||
|
<button type="submit" disabled={creating} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{u.name}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${
|
||||||
|
u.role === 'super_admin' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
|
||||||
|
u.role === 'admin' ? 'bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400' :
|
||||||
|
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageHomeowners() {
|
||||||
|
const [homeowners, setHomeowners] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { loadHomeowners(); }, []);
|
||||||
|
|
||||||
|
async function loadHomeowners() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/homeowners');
|
||||||
|
setHomeowners(res.data.homeowners || res.data || []);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addHomeowner(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await api.post('/admin/homeowners', { name: newName.trim() });
|
||||||
|
setNewName('');
|
||||||
|
loadHomeowners();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to add');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<form onSubmit={addHomeowner} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="New homeowner name..."
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={adding || !newName.trim()} className="px-4 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-1.5">
|
||||||
|
<Plus size={16} /> Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{homeowners.map((h) => (
|
||||||
|
<div key={h.id} className="flex items-center justify-between px-4 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{h.name}</span>
|
||||||
|
<span className={`text-xs ${h.isActive !== false ? 'text-emerald-500' : 'text-gray-400'}`}>
|
||||||
|
{h.isActive !== false ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reports() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [timesheets, setTimesheets] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '' });
|
||||||
|
const [selectedTs, setSelectedTs] = useState(null);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get('/admin/users'),
|
||||||
|
api.get('/admin/timesheets'),
|
||||||
|
]).then(([usersRes, tsRes]) => {
|
||||||
|
setUsers(usersRes.data.users || usersRes.data || []);
|
||||||
|
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
|
||||||
|
}).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.userId) params.set('userId', filters.userId);
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
const res = await api.get(`/admin/timesheets?${params}`);
|
||||||
|
setTimesheets(res.data.timesheets || res.data || []);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewDetail(ts) {
|
||||||
|
setSelectedTs(ts);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/admin/timesheets/${ts.id}`);
|
||||||
|
setDetail(res.data.timesheet || res.data);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
finally { setDetailLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateLocal(str) {
|
||||||
|
if (!str) return new Date();
|
||||||
|
const [y, m, d] = str.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (str) => {
|
||||||
|
if (!str) return '';
|
||||||
|
const d = parseDateLocal(str);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter timesheets client-side by date range too
|
||||||
|
const filtered = timesheets.filter((ts) => {
|
||||||
|
if (filters.startDate && ts.weekStart < filters.startDate) return false;
|
||||||
|
if (filters.endDate && ts.weekEnd > filters.endDate) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const totalHours = filtered.reduce((s, t) => s + (parseFloat(t.totalHours) || 0), 0);
|
||||||
|
const pending = filtered.filter((t) => t.status === 'submitted').length;
|
||||||
|
const approved = filtered.filter((t) => t.status === 'approved').length;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="bg-sky-50 dark:bg-sky-500/10 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-sky-600 dark:text-sky-400">{totalHours.toFixed(1)}</div>
|
||||||
|
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-500/10 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-amber-600 dark:text-amber-400">{pending}</div>
|
||||||
|
<div className="text-[10px] text-amber-500/70 font-medium">PENDING</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 dark:bg-emerald-500/10 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{approved}</div>
|
||||||
|
<div className="text-[10px] text-emerald-500/70 font-medium">APPROVED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||||
|
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300">Filters</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select value={filters.userId} onChange={(e) => setFilters({ ...filters, userId: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||||
|
<option value="">All Employees</option>
|
||||||
|
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" value={filters.startDate} onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Start" />
|
||||||
|
<input type="date" value={filters.endDate} onChange={(e) => setFilters({ ...filters, endDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="End" />
|
||||||
|
</div>
|
||||||
|
<button onClick={applyFilters} className="w-full py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 transition-all">
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timesheet List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No timesheets match filters</p>}
|
||||||
|
{filtered.map((ts) => (
|
||||||
|
<button key={ts.id} onClick={() => viewDetail(ts)} className="w-full text-left bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 hover:border-sky-300 dark:hover:border-sky-500/40 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">{ts.user?.name || ts.userName || 'Unknown'}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{fmtDate(ts.weekStart)} — {fmtDate(ts.weekEnd)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<StatusBadge status={ts.status} />
|
||||||
|
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mt-1">
|
||||||
|
{ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : '0h'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedTs && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4" onClick={() => { setSelectedTs(null); setDetail(null); }}>
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{selectedTs.user?.name || 'Employee'}</h3>
|
||||||
|
<button onClick={() => { setSelectedTs(null); setDetail(null); }} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={selectedTs.status} />
|
||||||
|
<span className="text-sm text-gray-500">{fmtDate(selectedTs.weekStart)} — {fmtDate(selectedTs.weekEnd)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Loader2 className="animate-spin text-sky-500" /></div>
|
||||||
|
) : detail ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(detail.entries || []).length === 0 && <p className="text-gray-400 text-sm text-center py-4">No entries</p>}
|
||||||
|
{(detail.entries || []).map((e) => {
|
||||||
|
const d = e.date?.split('T')[0] || e.date;
|
||||||
|
const [ey, em, ed] = (d || '').split('-').map(Number);
|
||||||
|
const entryDate = new Date(ey, em - 1, ed);
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="bg-gray-50 dark:bg-gray-800 rounded-xl p-3">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{e.homeownerName || 'Unknown'}</div>
|
||||||
|
<div className="text-xs text-gray-500">{entryDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-sky-600 dark:text-sky-400">{e.hoursWorked}h</span>
|
||||||
|
</div>
|
||||||
|
{e.workDescription && <p className="text-xs text-gray-500 mt-1">{e.workDescription}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 flex justify-between">
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Total</span>
|
||||||
|
<span className="font-bold text-lg text-gray-900 dark:text-white">
|
||||||
|
{(detail.entries || []).reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0).toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const [tab, setTab] = useState('reviews');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/timesheets?status=submitted')
|
||||||
|
.then((res) => setPendingCount((res.data.timesheets || res.data || []).length))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Admin</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||||
|
<TabButton active={tab === 'reviews'} onClick={() => setTab('reviews')} icon={Clock} label="Reviews" count={pendingCount} />
|
||||||
|
<TabButton active={tab === 'reports'} onClick={() => setTab('reports')} icon={Download} label="Reports" count={0} />
|
||||||
|
<TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" count={0} />
|
||||||
|
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'reviews' && <PendingReviews />}
|
||||||
|
{tab === 'reports' && <Reports />}
|
||||||
|
{tab === 'users' && <ManageUsers />}
|
||||||
|
{tab === 'homeowners' && <ManageHomeowners />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { Download, Loader2, Calendar, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function History() {
|
||||||
|
const [timesheets, setTimesheets] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/timesheets/history');
|
||||||
|
const data = res.data;
|
||||||
|
setTimesheets(Array.isArray(data) ? data : (data.timesheets || []));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load history:', err);
|
||||||
|
setTimesheets([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function downloadPdf(id, weekStart) {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/timesheets/${id}/pdf`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(res.data);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `timesheet-${weekStart}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
alert('Failed to download PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex justify-center py-20"><Loader2 size={32} className="animate-spin text-sky-500" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<Calendar size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">No timesheets yet</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Submit your first timesheet to see it here</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Timesheet History</h1>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{timesheets.map((ts) => {
|
||||||
|
const [sy, sm, sd] = ts.weekStart.split('-').map(Number);
|
||||||
|
const [ey, em, ed] = ts.weekEnd.split('-').map(Number);
|
||||||
|
const start = new Date(sy, sm - 1, sd);
|
||||||
|
const end = new Date(ey, em - 1, ed);
|
||||||
|
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<Clock size={20} className="text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{fmt(start)} — {fmt(end)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<StatusBadge status={ts.status} />
|
||||||
|
{ts.totalHours && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{parseFloat(ts.totalHours).toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(ts.status === 'submitted' || ts.status === 'approved') && (
|
||||||
|
<button
|
||||||
|
onClick={() => downloadPdf(ts.id, ts.weekStart)}
|
||||||
|
className="p-2.5 rounded-xl text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Invalid email or password');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-sky-500 to-teal-400 shadow-lg shadow-sky-500/25 mb-4">
|
||||||
|
<span className="text-2xl font-bold text-white">C</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Coastal Timesheet</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to track your hours</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl shadow-gray-200/50 dark:shadow-black/30 border border-gray-200/60 dark:border-gray-800/60 p-6 space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 text-sm rounded-xl px-4 py-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@coastal.com"
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-4 py-3 pr-12 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 text-white font-semibold text-base shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? <><Loader2 size={18} className="animate-spin" /> Signing in...</> : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-400 dark:text-gray-600 mt-6">
|
||||||
|
Coastal Contracting of FL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import WeekNavigator, { getWeekRange } from '../components/WeekNavigator';
|
||||||
|
import DayCard from '../components/DayCard';
|
||||||
|
import SaveIndicator from '../components/SaveIndicator';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { Send, Download, Mail, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
function toLocalDateStr(d) {
|
||||||
|
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekDates(date) {
|
||||||
|
const { start } = getWeekRange(date);
|
||||||
|
const dates = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(start.getDate() + i);
|
||||||
|
dates.push(toLocalDateStr(d));
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekParam(date) {
|
||||||
|
const { start } = getWeekRange(date);
|
||||||
|
return toLocalDateStr(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timesheet() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const [entries, setEntries] = useState({});
|
||||||
|
const [homeowners, setHomeowners] = useState([]);
|
||||||
|
const [timesheet, setTimesheet] = useState(null);
|
||||||
|
const [saveStatus, setSaveStatus] = useState('saved');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const saveTimer = useRef(null);
|
||||||
|
const pendingChanges = useRef({});
|
||||||
|
|
||||||
|
const weekDates = getWeekDates(selectedDate);
|
||||||
|
const weekParam = formatWeekParam(selectedDate);
|
||||||
|
// rejected timesheets can be edited and resubmitted
|
||||||
|
const timesheetId = timesheet?.id || timesheet?.timesheetId;
|
||||||
|
const isLocked = timesheet?.status === 'submitted' || timesheet?.status === 'approved';
|
||||||
|
|
||||||
|
// Load data for current week
|
||||||
|
const loadWeek = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [entriesRes, timesheetRes, homeownersRes] = await Promise.all([
|
||||||
|
api.get(`/entries?week=${weekParam}`),
|
||||||
|
api.get(`/timesheets?week=${weekParam}`).catch(() => ({ data: null })),
|
||||||
|
api.get('/homeowners'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Organize entries by date
|
||||||
|
const byDate = {};
|
||||||
|
weekDates.forEach((d) => { byDate[d] = []; });
|
||||||
|
(entriesRes.data.entries || entriesRes.data || []).forEach((e) => {
|
||||||
|
const dk = e.date?.split('T')[0] || e.date;
|
||||||
|
if (byDate[dk]) byDate[dk].push(e);
|
||||||
|
});
|
||||||
|
// Ensure at least one empty entry per day
|
||||||
|
weekDates.forEach((d) => {
|
||||||
|
if (byDate[d].length === 0) {
|
||||||
|
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setEntries(byDate);
|
||||||
|
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
||||||
|
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load week:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [weekParam]);
|
||||||
|
|
||||||
|
useEffect(() => { loadWeek(); }, [loadWeek]);
|
||||||
|
|
||||||
|
// Auto-save logic (debounced)
|
||||||
|
// Only save when entry has all required fields
|
||||||
|
function isEntryComplete(data) {
|
||||||
|
return data.date && data.homeownerId && parseFloat(data.hoursWorked) > 0 && data.workDescription?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEntry = useCallback(async (entryId, data) => {
|
||||||
|
// Don't save incomplete entries
|
||||||
|
if (!isEntryComplete(data)) {
|
||||||
|
setSaveStatus('saved'); // Reset indicator, not an error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus('saving');
|
||||||
|
// Normalize data for API
|
||||||
|
const payload = {
|
||||||
|
date: data.date,
|
||||||
|
homeownerId: data.homeownerId,
|
||||||
|
hoursWorked: parseFloat(data.hoursWorked),
|
||||||
|
workDescription: data.workDescription?.trim() || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data._isNew || entryId.startsWith('new-')) {
|
||||||
|
const res = await api.post('/entries', payload);
|
||||||
|
// Replace temp ID with real ID
|
||||||
|
setEntries((prev) => {
|
||||||
|
const dk = data.date;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.put(`/entries/${entryId}`, payload);
|
||||||
|
}
|
||||||
|
setSaveStatus('saved');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleEntryChange(entryId, updatedEntry) {
|
||||||
|
const dk = updatedEntry.date;
|
||||||
|
setEntries((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[dk]: prev[dk].map((e) => (e.id === entryId ? updatedEntry : e)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Debounced save
|
||||||
|
setSaveStatus('saving');
|
||||||
|
pendingChanges.current[entryId] = updatedEntry;
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
Object.entries(pendingChanges.current).forEach(([id, data]) => {
|
||||||
|
saveEntry(id, data);
|
||||||
|
});
|
||||||
|
pendingChanges.current = {};
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddEntry(date) {
|
||||||
|
const newEntry = {
|
||||||
|
id: `new-${date}-${Date.now()}`,
|
||||||
|
date,
|
||||||
|
homeownerId: '',
|
||||||
|
hoursWorked: '',
|
||||||
|
workDescription: '',
|
||||||
|
_isNew: true,
|
||||||
|
};
|
||||||
|
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEntry(entryId) {
|
||||||
|
try {
|
||||||
|
if (!entryId.startsWith('new-')) {
|
||||||
|
await api.delete(`/entries/${entryId}`);
|
||||||
|
}
|
||||||
|
setEntries((prev) => {
|
||||||
|
const updated = {};
|
||||||
|
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
||||||
|
const filtered = dayEntries.filter((e) => e.id !== entryId);
|
||||||
|
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setSaveStatus('saved');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Save any pending changes first
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
for (const [id, data] of Object.entries(pendingChanges.current)) {
|
||||||
|
await saveEntry(id, data);
|
||||||
|
}
|
||||||
|
pendingChanges.current = {};
|
||||||
|
|
||||||
|
const res = await api.post('/timesheets/submit', { weekStart: weekParam });
|
||||||
|
setTimesheet(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to submit timesheet');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadPdf() {
|
||||||
|
if (!timesheetId) return;
|
||||||
|
setPdfLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/timesheets/${timesheetId}/pdf`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(res.data);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `timesheet-${user.name}-${weekParam}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to generate PDF');
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHours = Object.values(entries)
|
||||||
|
.flat()
|
||||||
|
.reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
|
||||||
|
|
||||||
|
const today = toLocalDateStr(new Date());
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-sky-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Week Navigator + Status */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||||
|
<WeekNavigator selectedDate={selectedDate} onDateChange={setSelectedDate} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge status={timesheet?.status || 'draft'} />
|
||||||
|
<SaveIndicator status={saveStatus} />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">{totalHours.toFixed(1)}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">hours this week</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weekDates.map((date) => (
|
||||||
|
<DayCard
|
||||||
|
key={date}
|
||||||
|
date={date}
|
||||||
|
entries={entries[date] || []}
|
||||||
|
homeowners={homeowners}
|
||||||
|
onEntryChange={handleEntryChange}
|
||||||
|
onAddEntry={handleAddEntry}
|
||||||
|
onDeleteEntry={handleDeleteEntry}
|
||||||
|
disabled={isLocked}
|
||||||
|
defaultExpanded={date === today}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||||
|
{!isLocked ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || totalHours === 0}
|
||||||
|
className="w-full py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-semibold text-lg shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Timesheet'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={pdfLoading}
|
||||||
|
className="flex-1 py-3.5 rounded-xl bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-semibold flex items-center justify-center gap-2 hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||||
|
>
|
||||||
|
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (timesheetId) {
|
||||||
|
window.open(`mailto:?subject=Timesheet - ${user.name} - Week of ${weekParam}&body=Please find my timesheet attached.`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 py-3.5 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 font-semibold flex items-center justify-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||||
|
>
|
||||||
|
<Mail size={18} />
|
||||||
|
Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{timesheet?.status === 'rejected' && timesheet?.notes && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-red-600 dark:text-red-400">Rejected</p>
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400/80 mt-1">{timesheet.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ocean: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
950: '#082f49',
|
||||||
|
},
|
||||||
|
coastal: {
|
||||||
|
50: '#f0fdfa',
|
||||||
|
100: '#ccfbf1',
|
||||||
|
200: '#99f6e4',
|
||||||
|
300: '#5eead4',
|
||||||
|
400: '#2dd4bf',
|
||||||
|
500: '#14b8a6',
|
||||||
|
600: '#0d9488',
|
||||||
|
700: '#0f766e',
|
||||||
|
800: '#115e59',
|
||||||
|
900: '#134e4a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'SF Pro Display',
|
||||||
|
'SF Pro Text',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'2xl': '1rem',
|
||||||
|
'3xl': '1.5rem',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
'soft-lg': '0 10px 40px -10px rgba(0, 0, 0, 0.1), 0 2px 10px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
'inner-soft': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.04)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
'scale-in': 'scaleIn 0.2s ease-out',
|
||||||
|
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(-10px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
pulseSoft: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.5' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/forms')],
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Coastal Contracting Timesheet</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
# Enable gzip compression
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_proxied expired no-cache no-store private auth;
|
|
||||||
gzip_types
|
|
||||||
text/plain
|
|
||||||
text/css
|
|
||||||
text/xml
|
|
||||||
text/javascript
|
|
||||||
application/javascript
|
|
||||||
application/xml+rss
|
|
||||||
application/json;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Handle client-side routing (SPA)
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "coastal-timesheet",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@react-pdf/renderer": "^4.3.0",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"lucide-react": "^0.539.0",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.2.66",
|
|
||||||
"@types/react-dom": "^18.2.22",
|
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-plugin-react": "^7.34.1",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^5.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -1,76 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import TimeSheet from './components/TimeSheet';
|
|
||||||
import Help from './Help';
|
|
||||||
import ThemeToggle from './components/ThemeToggle';
|
|
||||||
import MiniCalendar from './components/MiniCalendar';
|
|
||||||
import { useTheme } from './hooks/useTheme';
|
|
||||||
import { useTimeSheet } from './hooks/useTimeSheet';
|
|
||||||
import { Calendar } from 'lucide-react';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const { isDark, toggleTheme } = useTheme();
|
|
||||||
const timesheetData = useTimeSheet();
|
|
||||||
const [showMobileCalendar, setShowMobileCalendar] = useState(false);
|
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
|
||||||
{/* Top right controls */}
|
|
||||||
<div className="fixed top-4 right-4 z-20 flex flex-col items-end gap-4 pointer-events-none">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
{/* Desktop calendar - always visible on large screens */}
|
|
||||||
<div className="hidden lg:block pointer-events-auto">
|
|
||||||
<MiniCalendar
|
|
||||||
selectedDate={timesheetData.selectedDate}
|
|
||||||
onDateChange={timesheetData.setSelectedDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile calendar toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMobileCalendar(!showMobileCalendar)}
|
|
||||||
className="lg:hidden p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors pointer-events-auto"
|
|
||||||
aria-label="Toggle calendar"
|
|
||||||
>
|
|
||||||
<Calendar className="h-5 w-5 text-gray-700 dark:text-gray-300" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<ThemeToggle isDark={isDark} toggleTheme={toggleTheme} />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHelp(true)}
|
|
||||||
className="pointer-events-auto px-3 py-2 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile calendar dropdown moved to content flow */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container mx-auto py-8 lg:pr-80 px-4">
|
|
||||||
{showHelp ? (
|
|
||||||
<Help onBack={() => setShowHelp(false)} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{showMobileCalendar && (
|
|
||||||
<div className="lg:hidden mb-4">
|
|
||||||
<MiniCalendar
|
|
||||||
selectedDate={timesheetData.selectedDate}
|
|
||||||
onDateChange={(date) => {
|
|
||||||
timesheetData.setSelectedDate(date);
|
|
||||||
setShowMobileCalendar(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TimeSheet timesheetData={timesheetData} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { ArrowLeft, Smartphone, Monitor, FileText, Share2, Upload } from 'lucide-react';
|
|
||||||
|
|
||||||
const Section = ({ title, children }) => (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 space-y-2">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300 space-y-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function Help({ onBack }) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Troubleshooting & How-To</h1>
|
|
||||||
<button onClick={onBack} className="flex items-center gap-2 px-3 py-2 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 hover:bg-gray-300 dark:hover:bg-gray-600">
|
|
||||||
<ArrowLeft className="h-4 w-4" /> Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 font-medium text-blue-800 dark:text-blue-200"><Smartphone className="h-4 w-4"/> iPhone/iPad</div>
|
|
||||||
<ul className="list-disc ml-5 mt-2 text-sm text-blue-900 dark:text-blue-100 space-y-1">
|
|
||||||
<li>Use Share/Email PDF to attach via Share Sheet</li>
|
|
||||||
<li>If To isn’t filled, import the Office contact first</li>
|
|
||||||
<li>Find downloads in Files → Downloads</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 font-medium text-green-800 dark:text-green-200">Android</div>
|
|
||||||
<ul className="list-disc ml-5 mt-2 text-sm text-green-900 dark:text-green-100 space-y-1">
|
|
||||||
<li>Pick Gmail or Email from the Share Sheet</li>
|
|
||||||
<li>Downloads are in Files/Downloads app</li>
|
|
||||||
<li>Add the Office contact to speed up addressing</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 font-medium text-purple-800 dark:text-purple-200"><Monitor className="h-4 w-4"/> Windows/Mac</div>
|
|
||||||
<ul className="list-disc ml-5 mt-2 text-sm text-purple-900 dark:text-purple-100 space-y-1">
|
|
||||||
<li>Use Download PDF then attach in Outlook/Mail</li>
|
|
||||||
<li>Export JSON to save/restore your timesheet</li>
|
|
||||||
<li>Subject should be: “Name - WeekRange Timesheet”</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Section title="Common actions">
|
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 font-medium"><FileText className="h-4 w-4"/> Download PDF</div>
|
|
||||||
<ol className="list-decimal ml-5 space-y-1">
|
|
||||||
<li>Tap Download PDF</li>
|
|
||||||
<li>Open your Downloads folder</li>
|
|
||||||
<li>Attach PDF to email, send to Office@CoastalContractingFL.com</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 font-medium"><Share2 className="h-4 w-4"/> Share/Email PDF (mobile)</div>
|
|
||||||
<ol className="list-decimal ml-5 space-y-1">
|
|
||||||
<li>Tap Share/Email PDF</li>
|
|
||||||
<li>Choose Mail/Gmail from the sheet</li>
|
|
||||||
<li>Subject prefilled; select Office contact if needed</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 font-medium"><Upload className="h-4 w-4"/> Export / Import</div>
|
|
||||||
<ol className="list-decimal ml-5 space-y-1">
|
|
||||||
<li>Export saves a JSON backup</li>
|
|
||||||
<li>Import restores a saved JSON file</li>
|
|
||||||
<li>Useful when switching devices or browsers</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="If something doesn’t work">
|
|
||||||
<ul className="list-disc ml-5 space-y-1">
|
|
||||||
<li>Try another browser (Safari/Chrome/Edge)</li>
|
|
||||||
<li>Ensure pop-ups/downloads are allowed</li>
|
|
||||||
<li>Check your device storage isn’t full</li>
|
|
||||||
<li>Manually email to Office@CoastalContractingFL.com with subject “Name - WeekRange Timesheet”</li>
|
|
||||||
</ul>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
|
||||||
import { formatWeekRange } from '../utils/dateUtils';
|
|
||||||
|
|
||||||
const DatePicker = ({ selectedDate, onDateChange, weekDays }) => {
|
|
||||||
const navigateWeek = (direction) => {
|
|
||||||
const newDate = new Date(selectedDate);
|
|
||||||
newDate.setDate(newDate.getDate() + (direction * 7));
|
|
||||||
onDateChange(newDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToToday = () => {
|
|
||||||
onDateChange(new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-lg shadow-md w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
||||||
Week of:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => navigateWeek(-1)}
|
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
|
|
||||||
aria-label="Previous week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-white text-sm sm:text-base text-center sm:text-left break-words">
|
|
||||||
{formatWeekRange(weekDays)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigateWeek(1)}
|
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
|
|
||||||
aria-label="Next week"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={goToToday}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DatePicker;
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
|
||||||
import HomeownerDropdown from './HomeownerDropdown';
|
|
||||||
import { getDayName } from '../utils/dateUtils';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
const SingleEntry = ({
|
|
||||||
entry,
|
|
||||||
onUpdate,
|
|
||||||
onRemove,
|
|
||||||
homeowners,
|
|
||||||
onAddCustomHomeowner,
|
|
||||||
canRemove,
|
|
||||||
getEntryValidation
|
|
||||||
}) => {
|
|
||||||
const textareaRef = useRef(null);
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onUpdate(field, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustTextareaHeight = (textarea) => {
|
|
||||||
if (textarea) {
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = Math.max(34, textarea.scrollHeight) + 'px';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight(textareaRef.current);
|
|
||||||
}, [entry.workDescription]);
|
|
||||||
|
|
||||||
const validation = getEntryValidation ? getEntryValidation(entry) : { isValid: true, isPartial: false, missingFields: {} };
|
|
||||||
|
|
||||||
const getFieldClassName = (baseClassName, fieldName) => {
|
|
||||||
if (!validation.isPartial) return baseClassName;
|
|
||||||
|
|
||||||
const isMissing = validation.missingFields[fieldName];
|
|
||||||
if (isMissing) {
|
|
||||||
return `${baseClassName} border-red-500 bg-red-50 dark:bg-red-900/20`;
|
|
||||||
}
|
|
||||||
return baseClassName;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 p-3 bg-white dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
|
||||||
Homeowner {validation.isPartial && validation.missingFields.homeowner && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<div className={validation.isPartial && validation.missingFields.homeowner ? 'border-2 border-red-500 rounded' : ''}>
|
|
||||||
<HomeownerDropdown
|
|
||||||
value={entry.homeowner}
|
|
||||||
onChange={(value) => handleChange('homeowner', value)}
|
|
||||||
homeowners={homeowners}
|
|
||||||
onAddCustom={onAddCustomHomeowner}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
|
||||||
Hours Worked {validation.isPartial && validation.missingFields.hoursWorked && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
min="0"
|
|
||||||
max="24"
|
|
||||||
value={entry.hoursWorked}
|
|
||||||
onChange={(e) => handleChange('hoursWorked', e.target.value)}
|
|
||||||
placeholder="0.0"
|
|
||||||
className={getFieldClassName("px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white placeholder-gray-400", "hoursWorked")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:col-span-2">
|
|
||||||
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
|
||||||
Work Description {validation.isPartial && validation.missingFields.workDescription && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={entry.workDescription}
|
|
||||||
onChange={(e) => handleChange('workDescription', e.target.value)}
|
|
||||||
placeholder="Describe work performed..."
|
|
||||||
rows={1}
|
|
||||||
className={getFieldClassName("px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white placeholder-gray-400 resize-none overflow-hidden", "workDescription")}
|
|
||||||
style={{
|
|
||||||
minHeight: '34px',
|
|
||||||
height: 'auto'
|
|
||||||
}}
|
|
||||||
onInput={(e) => adjustTextareaHeight(e.target)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-end">
|
|
||||||
{canRemove && (
|
|
||||||
<button
|
|
||||||
onClick={onRemove}
|
|
||||||
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors self-start"
|
|
||||||
aria-label="Remove entry"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DayEntries = ({
|
|
||||||
date,
|
|
||||||
entries,
|
|
||||||
onUpdate,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
homeowners,
|
|
||||||
onAddCustomHomeowner,
|
|
||||||
getEntryValidation
|
|
||||||
}) => {
|
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
|
||||||
const dayHours = entries.reduce((total, entry) => total + (parseFloat(entry.hoursWorked) || 0), 0);
|
|
||||||
|
|
||||||
// Check if any entries are incomplete
|
|
||||||
const hasIncompleteEntries = getEntryValidation && entries.some(entry => {
|
|
||||||
const validation = getEntryValidation(entry);
|
|
||||||
return validation.isPartial;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`p-4 rounded-lg border-2 transition-colors ${
|
|
||||||
isToday
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400'
|
|
||||||
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800'
|
|
||||||
}`}>
|
|
||||||
{/* Day Header */}
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
{getDayName(date)}
|
|
||||||
</h3>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{date.toLocaleDateString()} • {dayHours.toFixed(1)} hours
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onAdd(date)}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation Warning */}
|
|
||||||
{hasIncompleteEntries && (
|
|
||||||
<div className="mb-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md">
|
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
|
||||||
<span className="font-medium">⚠️ Incomplete entries:</span> All fields (homeowner, hours, and work description) are required when any field has input.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Entries */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<SingleEntry
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
onUpdate={(field, value) => onUpdate(date, entry.id, field, value)}
|
|
||||||
onRemove={() => onRemove(date, entry.id)}
|
|
||||||
homeowners={homeowners}
|
|
||||||
onAddCustomHomeowner={onAddCustomHomeowner}
|
|
||||||
canRemove={entries.length > 1}
|
|
||||||
getEntryValidation={getEntryValidation}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DayEntries;
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
import { Mail } from 'lucide-react';
|
|
||||||
import { formatWeekRange } from '../utils/dateUtils';
|
|
||||||
import TimesheetPDF from './TimesheetPDF';
|
|
||||||
|
|
||||||
const EmailPDF = ({ weekDays, timeEntries, userName, getEntryValidation }) => {
|
|
||||||
const handleEmail = async () => {
|
|
||||||
try {
|
|
||||||
const weekRange = formatWeekRange(weekDays);
|
|
||||||
const subject = `${userName || 'Employee'} - ${weekRange} Timesheet`;
|
|
||||||
|
|
||||||
// Generate PDF
|
|
||||||
const blob = await pdf(
|
|
||||||
<TimesheetPDF weekDays={weekDays} timeEntries={timeEntries} userName={userName} />
|
|
||||||
).toBlob();
|
|
||||||
const sanitizedRange = weekRange.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_');
|
|
||||||
const fileName = `Coastal_Timesheet_${sanitizedRange}.pdf`;
|
|
||||||
const file = new File([blob], fileName, { type: 'application/pdf' });
|
|
||||||
|
|
||||||
// Preferred: native share with attachment; some clients use 'title' as subject
|
|
||||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
||||||
try {
|
|
||||||
await navigator.share({ files: [file], title: subject, text: 'Timesheet attached.' });
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
// fall through to fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: download PDF and open mailto with subject only
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
const mailto = `mailto:?subject=${encodeURIComponent(subject)}`;
|
|
||||||
window.location.href = mailto;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error preparing email with PDF:', error);
|
|
||||||
alert('Error preparing email. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasEntries = Object.values(timeEntries).some((dayEntries) =>
|
|
||||||
dayEntries.some((entry) => entry.workDescription || entry.hoursWorked || entry.homeowner)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if there are any incomplete entries
|
|
||||||
const hasIncompleteEntries = getEntryValidation && Object.values(timeEntries).some(dayEntries =>
|
|
||||||
dayEntries.some(entry => {
|
|
||||||
const validation = getEntryValidation(entry);
|
|
||||||
return validation.isPartial;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasUserName = userName && userName.trim() !== '';
|
|
||||||
const canExport = hasEntries && !hasIncompleteEntries && hasUserName;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleEmail}
|
|
||||||
disabled={!canExport}
|
|
||||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
|
|
||||||
canExport
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
!hasEntries
|
|
||||||
? 'No data to email'
|
|
||||||
: !hasUserName
|
|
||||||
? 'Employee name is required to email PDF'
|
|
||||||
: hasIncompleteEntries
|
|
||||||
? 'Complete all partially filled entries before emailing PDF'
|
|
||||||
: 'Attach PDF via share (if supported) or download it, and open email with subject pre-filled'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Mail className="h-5 w-5" />
|
|
||||||
Share/Email PDF
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailPDF;
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { ChevronDown, Plus, Search } from 'lucide-react';
|
|
||||||
|
|
||||||
const HomeownerDropdown = ({ value, onChange, homeowners, onAddCustom }) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [customName, setCustomName] = useState('');
|
|
||||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const handleSelect = (homeowner) => {
|
|
||||||
onChange(homeowner);
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearchTerm('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredHomeowners = homeowners.filter(homeowner =>
|
|
||||||
homeowner.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setSearchTerm('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCustom = () => {
|
|
||||||
if (customName.trim()) {
|
|
||||||
onAddCustom(customName.trim());
|
|
||||||
onChange(customName.trim());
|
|
||||||
setCustomName('');
|
|
||||||
setShowCustomInput(false);
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCustomInput = () => {
|
|
||||||
setShowCustomInput(!showCustomInput);
|
|
||||||
setCustomName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpen}
|
|
||||||
className="w-full px-3 py-2 text-left bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-white"
|
|
||||||
>
|
|
||||||
<span className="block truncate">
|
|
||||||
{value || 'Select homeowner...'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg">
|
|
||||||
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
placeholder="Search homeowners..."
|
|
||||||
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-60 overflow-auto">
|
|
||||||
{filteredHomeowners.length > 0 ? (
|
|
||||||
filteredHomeowners.map((homeowner, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelect(homeowner)}
|
|
||||||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
{homeowner}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
No homeowners found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-600">
|
|
||||||
{!showCustomInput ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCustomInput}
|
|
||||||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 text-blue-600 dark:text-blue-400 flex items-center"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Custom Name
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="p-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customName}
|
|
||||||
onChange={(e) => setCustomName(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleAddCustom()}
|
|
||||||
placeholder="Enter custom name..."
|
|
||||||
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddCustom}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCustomInput}
|
|
||||||
className="px-3 py-1 text-sm bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HomeownerDropdown;
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import { Download, Upload, Save, FolderOpen } from 'lucide-react';
|
|
||||||
import { formatWeekRange } from '../utils/dateUtils';
|
|
||||||
|
|
||||||
const ImportExport = ({ weekDays, timeEntries, customHomeowners, onImport }) => {
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
try {
|
|
||||||
const exportData = {
|
|
||||||
version: '1.0',
|
|
||||||
exportDate: new Date().toISOString(),
|
|
||||||
weekRange: formatWeekRange(weekDays),
|
|
||||||
weekStart: weekDays[0].toISOString().split('T')[0],
|
|
||||||
weekEnd: weekDays[6].toISOString().split('T')[0],
|
|
||||||
timeEntries,
|
|
||||||
customHomeowners
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataStr = JSON.stringify(exportData, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `Coastal_Timesheet_${formatWeekRange(weekDays).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}.json`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
|
|
||||||
notification.textContent = 'Timesheet exported successfully!';
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(notification)) {
|
|
||||||
document.body.removeChild(notification);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting timesheet:', error);
|
|
||||||
alert('Error exporting timesheet. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
if (file.type !== 'application/json') {
|
|
||||||
alert('Please select a valid JSON file.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const importData = JSON.parse(e.target.result);
|
|
||||||
|
|
||||||
// Validate the imported data structure
|
|
||||||
if (!importData.version || !importData.timeEntries) {
|
|
||||||
alert('Invalid timesheet file format.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate data structure
|
|
||||||
const isValidStructure = Object.values(importData.timeEntries).every(dayEntries =>
|
|
||||||
Array.isArray(dayEntries) && dayEntries.every(entry =>
|
|
||||||
typeof entry === 'object' &&
|
|
||||||
'id' in entry &&
|
|
||||||
'workDescription' in entry &&
|
|
||||||
'hoursWorked' in entry &&
|
|
||||||
'homeowner' in entry
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidStructure) {
|
|
||||||
alert('Invalid timesheet data structure.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm import
|
|
||||||
const confirmImport = confirm(
|
|
||||||
`Import timesheet data from ${importData.weekRange || 'unknown week'}?\n\nThis will replace your current timesheet data for this week.`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmImport) {
|
|
||||||
onImport(importData);
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
|
|
||||||
notification.textContent = 'Timesheet imported successfully!';
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(notification)) {
|
|
||||||
document.body.removeChild(notification);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing timesheet:', error);
|
|
||||||
alert('Error reading timesheet file. Please check the file format.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsText(file);
|
|
||||||
|
|
||||||
// Reset file input
|
|
||||||
event.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasData = Object.values(timeEntries).some(dayEntries =>
|
|
||||||
dayEntries.some(entry => entry.workDescription || entry.hoursWorked || entry.homeowner)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Export Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={!hasData}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
hasData
|
|
||||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
title={hasData ? 'Export a JSON backup of your timesheet (for re-import later)' : 'No data to export'}
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Import Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleImportClick}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
|
|
||||||
title="Import a previously exported JSON timesheet"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Hidden File Input */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImportExport;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
const MiniCalendar = ({ selectedDate, onDateChange }) => {
|
|
||||||
const [viewDate, setViewDate] = useState(new Date());
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
const monthNames = [
|
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
|
||||||
];
|
|
||||||
|
|
||||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
||||||
|
|
||||||
const getDaysInMonth = (date) => {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth();
|
|
||||||
const firstDay = new Date(year, month, 1);
|
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
|
||||||
const daysInMonth = lastDay.getDate();
|
|
||||||
const startingDayOfWeek = firstDay.getDay();
|
|
||||||
|
|
||||||
const days = [];
|
|
||||||
|
|
||||||
// Add previous month's trailing days
|
|
||||||
const prevMonth = new Date(year, month - 1, 0);
|
|
||||||
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
|
|
||||||
days.push({
|
|
||||||
day: prevMonth.getDate() - i,
|
|
||||||
isCurrentMonth: false,
|
|
||||||
date: new Date(prevMonth.getFullYear(), prevMonth.getMonth(), prevMonth.getDate() - i)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current month days
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
days.push({
|
|
||||||
day,
|
|
||||||
isCurrentMonth: true,
|
|
||||||
date: new Date(year, month, day)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add next month's leading days
|
|
||||||
const remainingSlots = 42 - days.length; // 6 rows × 7 days = 42
|
|
||||||
for (let day = 1; day <= remainingSlots; day++) {
|
|
||||||
days.push({
|
|
||||||
day,
|
|
||||||
isCurrentMonth: false,
|
|
||||||
date: new Date(year, month + 1, day)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return days;
|
|
||||||
};
|
|
||||||
|
|
||||||
const days = getDaysInMonth(viewDate);
|
|
||||||
|
|
||||||
const navigateMonth = (direction) => {
|
|
||||||
const newDate = new Date(viewDate);
|
|
||||||
newDate.setMonth(newDate.getMonth() + direction);
|
|
||||||
setViewDate(newDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToToday = () => {
|
|
||||||
setViewDate(new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateClick = (date) => {
|
|
||||||
onDateChange(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isToday = (date) => {
|
|
||||||
return date.toDateString() === today.toDateString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = (date) => {
|
|
||||||
return date.toDateString() === selectedDate.toDateString();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-800 text-white rounded-lg p-4 shadow-lg w-full max-w-sm">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium">
|
|
||||||
{monthNames[viewDate.getMonth()]} {viewDate.getFullYear()}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => navigateMonth(-1)}
|
|
||||||
className="p-1 rounded hover:bg-gray-700 transition-colors"
|
|
||||||
aria-label="Previous month"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={goToToday}
|
|
||||||
className="px-3 py-1 text-sm bg-gray-600 hover:bg-gray-500 rounded transition-colors"
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigateMonth(1)}
|
|
||||||
className="p-1 rounded hover:bg-gray-700 transition-colors"
|
|
||||||
aria-label="Next month"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day headers */}
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
||||||
{dayNames.map((day) => (
|
|
||||||
<div
|
|
||||||
key={day}
|
|
||||||
className="text-center text-xs font-medium text-gray-400 py-1"
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar grid */}
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
|
||||||
{days.map((dayObj, index) => {
|
|
||||||
const { day, isCurrentMonth, date } = dayObj;
|
|
||||||
const todayHighlight = isToday(date);
|
|
||||||
const selectedHighlight = isSelected(date);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => handleDateClick(date)}
|
|
||||||
className={`
|
|
||||||
relative h-8 text-sm font-medium rounded transition-colors
|
|
||||||
${isCurrentMonth
|
|
||||||
? 'text-white hover:bg-gray-700'
|
|
||||||
: 'text-gray-500 hover:bg-gray-700'
|
|
||||||
}
|
|
||||||
${selectedHighlight
|
|
||||||
? 'bg-blue-600 hover:bg-blue-500'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
{todayHighlight && !selectedHighlight && (
|
|
||||||
<div className="absolute inset-0 bg-red-500 rounded-full w-6 h-6 mx-auto my-1 flex items-center justify-center">
|
|
||||||
<span className="text-white text-xs font-bold">{day}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MiniCalendar;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
BEGIN:VCARD
|
|
||||||
VERSION:3.0
|
|
||||||
FN:Coastal Contracting Office
|
|
||||||
N:Office;Coastal Contracting;;;
|
|
||||||
EMAIL;TYPE=INTERNET;TYPE=WORK:Office@CoastalContractingFL.com
|
|
||||||
ORG:Coastal Contracting of FL
|
|
||||||
END:VCARD
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
import { Download } from 'lucide-react';
|
|
||||||
import { formatWeekRange } from '../utils/dateUtils';
|
|
||||||
import TimesheetPDF from './TimesheetPDF';
|
|
||||||
|
|
||||||
const PDFExport = ({ weekDays, timeEntries, userName, getEntryValidation }) => {
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
const blob = await pdf(
|
|
||||||
<TimesheetPDF weekDays={weekDays} timeEntries={timeEntries} userName={userName} />
|
|
||||||
).toBlob();
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `Coastal_Timesheet_${formatWeekRange(weekDays).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}.pdf`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating PDF:', error);
|
|
||||||
alert('Error generating PDF. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasEntries = Object.values(timeEntries).some(dayEntries =>
|
|
||||||
dayEntries.some(entry => entry.workDescription || entry.hoursWorked || entry.homeowner)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if there are any incomplete entries
|
|
||||||
const hasIncompleteEntries = getEntryValidation && Object.values(timeEntries).some(dayEntries =>
|
|
||||||
dayEntries.some(entry => {
|
|
||||||
const validation = getEntryValidation(entry);
|
|
||||||
return validation.isPartial;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasUserName = userName && userName.trim() !== '';
|
|
||||||
const canExport = hasEntries && !hasIncompleteEntries && hasUserName;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!canExport}
|
|
||||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
|
|
||||||
canExport
|
|
||||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
!hasEntries
|
|
||||||
? 'No data to export as PDF'
|
|
||||||
: !hasUserName
|
|
||||||
? 'Employee name is required to export PDF'
|
|
||||||
: hasIncompleteEntries
|
|
||||||
? 'Complete all partially filled entries before exporting PDF'
|
|
||||||
: 'Download a printable PDF of your timesheet'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
Download PDF
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PDFExport;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Sun, Moon } from 'lucide-react';
|
|
||||||
|
|
||||||
const ThemeToggle = ({ isDark, toggleTheme }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
|
||||||
{isDark ? (
|
|
||||||
<Sun className="h-5 w-5 text-yellow-500" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-5 w-5 text-gray-700" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeToggle;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import HomeownerDropdown from './HomeownerDropdown';
|
|
||||||
import { getDayName } from '../utils/dateUtils';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
const TimeEntryRow = ({
|
|
||||||
date,
|
|
||||||
entry,
|
|
||||||
onUpdate,
|
|
||||||
homeowners,
|
|
||||||
onAddCustomHomeowner,
|
|
||||||
getEntryValidation
|
|
||||||
}) => {
|
|
||||||
const textareaRef = useRef(null);
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
onUpdate(date, field, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustTextareaHeight = (textarea) => {
|
|
||||||
if (textarea) {
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = Math.max(38, textarea.scrollHeight) + 'px';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight(textareaRef.current);
|
|
||||||
}, [entry.workDescription]);
|
|
||||||
|
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
|
||||||
const validation = getEntryValidation ? getEntryValidation(entry) : { isValid: true, isPartial: false, missingFields: {} };
|
|
||||||
|
|
||||||
const getFieldClassName = (baseClassName, fieldName) => {
|
|
||||||
if (!validation.isPartial) return baseClassName;
|
|
||||||
|
|
||||||
const isMissing = validation.missingFields[fieldName];
|
|
||||||
if (isMissing) {
|
|
||||||
return `${baseClassName} border-red-500 bg-red-50 dark:bg-red-900/20`;
|
|
||||||
}
|
|
||||||
return baseClassName;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`grid grid-cols-1 md:grid-cols-5 gap-4 p-4 rounded-lg border-2 transition-colors ${
|
|
||||||
isToday
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400'
|
|
||||||
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800'
|
|
||||||
}`}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{getDayName(date)}
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{date.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Homeowner {validation.isPartial && validation.missingFields.homeowner && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<div className={validation.isPartial && validation.missingFields.homeowner ? 'border-2 border-red-500 rounded-md' : ''}>
|
|
||||||
<HomeownerDropdown
|
|
||||||
value={entry.homeowner}
|
|
||||||
onChange={(value) => handleChange('homeowner', value)}
|
|
||||||
homeowners={homeowners}
|
|
||||||
onAddCustom={onAddCustomHomeowner}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Hours Worked {validation.isPartial && validation.missingFields.hoursWorked && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
min="0"
|
|
||||||
max="24"
|
|
||||||
value={entry.hoursWorked}
|
|
||||||
onChange={(e) => handleChange('hoursWorked', e.target.value)}
|
|
||||||
placeholder="0.0"
|
|
||||||
className={getFieldClassName("px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white placeholder-gray-400", "hoursWorked")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Work Description {validation.isPartial && validation.missingFields.workDescription && <span className="text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={entry.workDescription}
|
|
||||||
onChange={(e) => handleChange('workDescription', e.target.value)}
|
|
||||||
placeholder="Describe work performed..."
|
|
||||||
rows={1}
|
|
||||||
className={getFieldClassName("px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white placeholder-gray-400 resize-none overflow-hidden", "workDescription")}
|
|
||||||
style={{
|
|
||||||
minHeight: '38px',
|
|
||||||
height: 'auto'
|
|
||||||
}}
|
|
||||||
onInput={(e) => adjustTextareaHeight(e.target)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimeEntryRow;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import DatePicker from './DatePicker';
|
|
||||||
import DayEntries from './DayEntries';
|
|
||||||
import PDFExport from './PDFExport';
|
|
||||||
import EmailPDF from './EmailPDF';
|
|
||||||
import officeContact from './OfficeContact.vcf?url';
|
|
||||||
import ImportExport from './ImportExport';
|
|
||||||
|
|
||||||
const TimeSheet = ({ timesheetData }) => {
|
|
||||||
const {
|
|
||||||
selectedDate,
|
|
||||||
setSelectedDate,
|
|
||||||
weekDays,
|
|
||||||
timeEntries,
|
|
||||||
updateTimeEntry,
|
|
||||||
addTimeEntry,
|
|
||||||
removeTimeEntry,
|
|
||||||
allHomeowners,
|
|
||||||
addCustomHomeowner,
|
|
||||||
customHomeowners,
|
|
||||||
importTimesheet,
|
|
||||||
userName,
|
|
||||||
setUserName,
|
|
||||||
getEntryValidation
|
|
||||||
} = timesheetData;
|
|
||||||
|
|
||||||
const totalHours = Object.values(timeEntries).reduce((total, dayEntries) => {
|
|
||||||
const dayTotal = dayEntries.reduce((daySum, entry) => {
|
|
||||||
return daySum + (parseFloat(entry.hoursWorked) || 0);
|
|
||||||
}, 0);
|
|
||||||
return total + dayTotal;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
COASTAL CONTRACTING OF FL TIME SHEET
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
Track your daily work hours and generate timesheets Sherry can actually read
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-end">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label htmlFor="userName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Employee Name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="userName"
|
|
||||||
value={userName}
|
|
||||||
onChange={(e) => setUserName(e.target.value)}
|
|
||||||
placeholder="Enter your name"
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
userName.trim() === ''
|
|
||||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20 dark:border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{userName.trim() === '' && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
|
||||||
Employee name is required for timesheet submission
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<DatePicker
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
onDateChange={setSelectedDate}
|
|
||||||
weekDays={weekDays}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
Time Entries
|
|
||||||
</h2>
|
|
||||||
<div className="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
Total: {totalHours.toFixed(1)} hours
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{weekDays.map((day) => {
|
|
||||||
const dayKey = day.toISOString().split('T')[0];
|
|
||||||
const dayEntries = timeEntries[dayKey] || [{
|
|
||||||
id: `${dayKey}-0`,
|
|
||||||
workDescription: '',
|
|
||||||
hoursWorked: '',
|
|
||||||
homeowner: ''
|
|
||||||
}];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DayEntries
|
|
||||||
key={dayKey}
|
|
||||||
date={day}
|
|
||||||
entries={dayEntries}
|
|
||||||
onUpdate={updateTimeEntry}
|
|
||||||
onAdd={addTimeEntry}
|
|
||||||
onRemove={removeTimeEntry}
|
|
||||||
homeowners={allHomeowners}
|
|
||||||
onAddCustomHomeowner={addCustomHomeowner}
|
|
||||||
getEntryValidation={getEntryValidation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2 pt-6">
|
|
||||||
<div className="flex flex-wrap justify-center items-center gap-3">
|
|
||||||
<ImportExport
|
|
||||||
weekDays={weekDays}
|
|
||||||
timeEntries={timeEntries}
|
|
||||||
customHomeowners={customHomeowners}
|
|
||||||
onImport={importTimesheet}
|
|
||||||
/>
|
|
||||||
<PDFExport
|
|
||||||
weekDays={weekDays}
|
|
||||||
timeEntries={timeEntries}
|
|
||||||
userName={userName}
|
|
||||||
getEntryValidation={getEntryValidation}
|
|
||||||
/>
|
|
||||||
<EmailPDF
|
|
||||||
weekDays={weekDays}
|
|
||||||
timeEntries={timeEntries}
|
|
||||||
userName={userName}
|
|
||||||
getEntryValidation={getEntryValidation}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={officeContact}
|
|
||||||
download="Coastal_Office_Contact.vcf"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded-lg font-medium transition-colors"
|
|
||||||
title="Download and import the office contact to your phone so the To field is one tap away"
|
|
||||||
>
|
|
||||||
Import Office Contact
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 text-center max-w-xl">
|
|
||||||
<p className="mb-1">
|
|
||||||
Export: Save a JSON backup that you can import later. Download PDF: Save a printable copy. Share/Email PDF: Attach the PDF via your device share sheet or download and open email with the subject pre-filled.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If all else fails: email your timesheet to <span className="font-medium">Office@CoastalContractingFL.com</span> with subject
|
|
||||||
<span className="font-medium"> "{userName || 'Employee'} - {weekDays && weekDays.length ? weekDays[0].toLocaleDateString() + ' - ' + weekDays[6].toLocaleDateString() : 'Timesheet'}"</span>.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
Tip: You can add a contact for Office@CoastalContractingFL.com on your device so the To field is one tap away.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimeSheet;
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
|
|
||||||
import { formatWeekRange, getDayName } from '../utils/dateUtils';
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
fontFamily: 'Helvetica',
|
|
||||||
fontSize: 10,
|
|
||||||
paddingTop: 25,
|
|
||||||
paddingBottom: 40,
|
|
||||||
paddingHorizontal: 30,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
},
|
|
||||||
headerSection: {
|
|
||||||
marginBottom: 20,
|
|
||||||
paddingBottom: 10,
|
|
||||||
borderBottomWidth: 2,
|
|
||||||
borderBottomColor: '#e5e7eb',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
fontSize: 20,
|
|
||||||
marginBottom: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1f2937',
|
|
||||||
letterSpacing: 1.0,
|
|
||||||
},
|
|
||||||
brandLine: {
|
|
||||||
width: 60,
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginBottom: 5,
|
|
||||||
},
|
|
||||||
weekInfo: {
|
|
||||||
fontSize: 14,
|
|
||||||
marginBottom: 18,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#374151',
|
|
||||||
backgroundColor: '#f8fafc',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
daySection: {
|
|
||||||
marginBottom: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
},
|
|
||||||
dayHeader: {
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
dayHeaderEmpty: {
|
|
||||||
backgroundColor: '#9ca3af',
|
|
||||||
},
|
|
||||||
dayName: {
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#ffffff',
|
|
||||||
},
|
|
||||||
dayDate: {
|
|
||||||
fontSize: 9,
|
|
||||||
color: '#dbeafe',
|
|
||||||
},
|
|
||||||
dayTotal: {
|
|
||||||
fontSize: 9,
|
|
||||||
color: '#dbeafe',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
entryRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#f3f4f6',
|
|
||||||
minHeight: 28,
|
|
||||||
},
|
|
||||||
entryRowLast: {
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
},
|
|
||||||
entryRowAlternate: {
|
|
||||||
backgroundColor: '#f9fafb',
|
|
||||||
},
|
|
||||||
entryCell: {
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
fontSize: 9,
|
|
||||||
color: '#374151',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
homeownerCell: {
|
|
||||||
width: '25%',
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#e5e7eb',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1f2937',
|
|
||||||
},
|
|
||||||
hoursCell: {
|
|
||||||
width: '15%',
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#e5e7eb',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#059669',
|
|
||||||
},
|
|
||||||
workDescCell: {
|
|
||||||
width: '60%',
|
|
||||||
},
|
|
||||||
emptyDay: {
|
|
||||||
backgroundColor: '#f9fafb',
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emptyDayText: {
|
|
||||||
fontSize: 9,
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
summarySection: {
|
|
||||||
marginTop: 18,
|
|
||||||
paddingTop: 12,
|
|
||||||
borderTopWidth: 2,
|
|
||||||
borderTopColor: '#3b82f6',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
summaryText: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1f2937',
|
|
||||||
},
|
|
||||||
totalHours: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#059669',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
position: 'absolute',
|
|
||||||
fontSize: 8,
|
|
||||||
bottom: 25,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#9ca3af',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const TimesheetPDF = ({ weekDays, timeEntries, userName }) => {
|
|
||||||
const totalHours = Object.values(timeEntries).reduce((total, dayEntries) => {
|
|
||||||
const dayTotal = dayEntries.reduce((daySum, entry) => {
|
|
||||||
return daySum + (parseFloat(entry.hoursWorked) || 0);
|
|
||||||
}, 0);
|
|
||||||
return total + dayTotal;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const getDayTotal = (dayKey) => {
|
|
||||||
const dayEntries = timeEntries[dayKey] || [];
|
|
||||||
return dayEntries.reduce((total, entry) => total + (parseFloat(entry.hoursWorked) || 0), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date) => {
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Document>
|
|
||||||
<Page size="A4" style={styles.page}>
|
|
||||||
<View style={styles.headerSection}>
|
|
||||||
<View style={styles.brandLine}></View>
|
|
||||||
<Text style={styles.header}>
|
|
||||||
COASTAL CONTRACTING OF FL
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.weekInfo}>
|
|
||||||
<Text>Week of: {formatWeekRange(weekDays)}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{userName && (
|
|
||||||
<View style={{ ...styles.weekInfo, marginBottom: 12, backgroundColor: '#f0f9ff' }}>
|
|
||||||
<Text>Employee: {userName}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{weekDays.map((day, dayIndex) => {
|
|
||||||
const dayKey = day.toISOString().split('T')[0];
|
|
||||||
const dayEntries = timeEntries[dayKey] || [];
|
|
||||||
const dayTotal = getDayTotal(dayKey);
|
|
||||||
const hasEntries = dayEntries.length > 0 && dayEntries.some(entry =>
|
|
||||||
entry.workDescription || entry.hoursWorked || entry.homeowner
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip days with no work
|
|
||||||
if (!hasEntries) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.daySection} key={dayIndex}>
|
|
||||||
{/* Day Header */}
|
|
||||||
<View style={styles.dayHeader}>
|
|
||||||
<View>
|
|
||||||
<Text style={styles.dayName}>{getDayName(day)}</Text>
|
|
||||||
<Text style={styles.dayDate}>{formatDate(day)}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.dayTotal}>
|
|
||||||
{dayTotal.toFixed(1)} hours
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Day Content */}
|
|
||||||
{dayEntries.map((entry, entryIndex) => {
|
|
||||||
const isLastEntry = entryIndex === dayEntries.length - 1;
|
|
||||||
const isAlternate = entryIndex % 2 === 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.entryRow,
|
|
||||||
isLastEntry && styles.entryRowLast,
|
|
||||||
isAlternate && styles.entryRowAlternate
|
|
||||||
]}
|
|
||||||
key={entryIndex}
|
|
||||||
>
|
|
||||||
<View style={[styles.entryCell, styles.homeownerCell]}>
|
|
||||||
<Text>{entry.homeowner || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.entryCell, styles.hoursCell]}>
|
|
||||||
<Text>{entry.hoursWorked || '0'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.entryCell, styles.workDescCell]}>
|
|
||||||
<Text>{entry.workDescription || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<View style={styles.summarySection}>
|
|
||||||
<Text style={styles.summaryText}>
|
|
||||||
Weekly Total
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.totalHours}>
|
|
||||||
{totalHours.toFixed(1)} Hours
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.footer}>
|
|
||||||
Generated on {new Date().toLocaleDateString()} • Coastal Contracting of FL{'\n'}Built by the warehouse guy
|
|
||||||
</Text>
|
|
||||||
</Page>
|
|
||||||
</Document>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimesheetPDF;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export const useTheme = () => {
|
|
||||||
const [isDark, setIsDark] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('theme');
|
|
||||||
return saved ? saved === 'dark' : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}, [isDark]);
|
|
||||||
|
|
||||||
const toggleTheme = () => setIsDark(!isDark);
|
|
||||||
|
|
||||||
return { isDark, toggleTheme };
|
|
||||||
};
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { getWeekDays } from '../utils/dateUtils';
|
|
||||||
|
|
||||||
export const useTimeSheet = () => {
|
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
|
||||||
const [timeEntries, setTimeEntries] = useState({});
|
|
||||||
const [userName, setUserName] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('userName');
|
|
||||||
return saved || '';
|
|
||||||
});
|
|
||||||
const [customHomeowners, setCustomHomeowners] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('customHomeowners');
|
|
||||||
return saved ? JSON.parse(saved) : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const weekDays = getWeekDays(selectedDate);
|
|
||||||
const weekKey = `${weekDays[0].toISOString().split('T')[0]}_${weekDays[6].toISOString().split('T')[0]}`;
|
|
||||||
|
|
||||||
// Load timesheet data for current week
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(`timesheet_${weekKey}`);
|
|
||||||
if (saved) {
|
|
||||||
const savedData = JSON.parse(saved);
|
|
||||||
// Migrate old single-entry format to new array format
|
|
||||||
const migratedData = {};
|
|
||||||
Object.keys(savedData).forEach(dayKey => {
|
|
||||||
if (Array.isArray(savedData[dayKey])) {
|
|
||||||
migratedData[dayKey] = savedData[dayKey];
|
|
||||||
} else {
|
|
||||||
// Convert old format to new format
|
|
||||||
migratedData[dayKey] = [savedData[dayKey]];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setTimeEntries(migratedData);
|
|
||||||
} else {
|
|
||||||
// Initialize empty entries for the week
|
|
||||||
const initialEntries = {};
|
|
||||||
weekDays.forEach(day => {
|
|
||||||
const dayKey = day.toISOString().split('T')[0];
|
|
||||||
initialEntries[dayKey] = [{
|
|
||||||
id: `${dayKey}-0`,
|
|
||||||
workDescription: '',
|
|
||||||
hoursWorked: '',
|
|
||||||
homeowner: ''
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
setTimeEntries(initialEntries);
|
|
||||||
}
|
|
||||||
}, [weekKey]);
|
|
||||||
|
|
||||||
// Save timesheet data when it changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (Object.keys(timeEntries).length > 0) {
|
|
||||||
localStorage.setItem(`timesheet_${weekKey}`, JSON.stringify(timeEntries));
|
|
||||||
}
|
|
||||||
}, [timeEntries, weekKey]);
|
|
||||||
|
|
||||||
// Save custom homeowners when they change
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('customHomeowners', JSON.stringify(customHomeowners));
|
|
||||||
}, [customHomeowners]);
|
|
||||||
|
|
||||||
// Save user name when it changes
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('userName', userName);
|
|
||||||
}, [userName]);
|
|
||||||
|
|
||||||
const updateTimeEntry = (date, entryId, field, value) => {
|
|
||||||
const dayKey = date.toISOString().split('T')[0];
|
|
||||||
setTimeEntries(prev => ({
|
|
||||||
...prev,
|
|
||||||
[dayKey]: prev[dayKey].map(entry =>
|
|
||||||
entry.id === entryId
|
|
||||||
? { ...entry, [field]: value }
|
|
||||||
: entry
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTimeEntry = (date) => {
|
|
||||||
const dayKey = date.toISOString().split('T')[0];
|
|
||||||
const newId = `${dayKey}-${Date.now()}`;
|
|
||||||
setTimeEntries(prev => ({
|
|
||||||
...prev,
|
|
||||||
[dayKey]: [
|
|
||||||
...prev[dayKey],
|
|
||||||
{
|
|
||||||
id: newId,
|
|
||||||
workDescription: '',
|
|
||||||
hoursWorked: '',
|
|
||||||
homeowner: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTimeEntry = (date, entryId) => {
|
|
||||||
const dayKey = date.toISOString().split('T')[0];
|
|
||||||
setTimeEntries(prev => {
|
|
||||||
const dayEntries = prev[dayKey];
|
|
||||||
// Don't allow removing the last entry
|
|
||||||
if (dayEntries.length <= 1) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[dayKey]: dayEntries.filter(entry => entry.id !== entryId)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCustomHomeowner = (name) => {
|
|
||||||
if (name && !customHomeowners.includes(name)) {
|
|
||||||
setCustomHomeowners(prev => [...prev, name]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const importTimesheet = (importData) => {
|
|
||||||
// Import time entries
|
|
||||||
if (importData.timeEntries) {
|
|
||||||
setTimeEntries(importData.timeEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import custom homeowners (merge with existing ones)
|
|
||||||
if (importData.customHomeowners && Array.isArray(importData.customHomeowners)) {
|
|
||||||
setCustomHomeowners(prev => {
|
|
||||||
const merged = [...prev];
|
|
||||||
importData.customHomeowners.forEach(name => {
|
|
||||||
if (!merged.includes(name)) {
|
|
||||||
merged.push(name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return merged;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validation function to check if an entry is complete
|
|
||||||
const isEntryComplete = (entry) => {
|
|
||||||
return entry.homeowner.trim() !== '' &&
|
|
||||||
entry.hoursWorked.trim() !== '' &&
|
|
||||||
entry.workDescription.trim() !== '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if an entry has any input (partial entry)
|
|
||||||
const hasAnyInput = (entry) => {
|
|
||||||
return entry.homeowner.trim() !== '' ||
|
|
||||||
entry.hoursWorked.trim() !== '' ||
|
|
||||||
entry.workDescription.trim() !== '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get validation state for an entry
|
|
||||||
const getEntryValidation = (entry) => {
|
|
||||||
const hasInput = hasAnyInput(entry);
|
|
||||||
const isComplete = isEntryComplete(entry);
|
|
||||||
|
|
||||||
if (!hasInput) {
|
|
||||||
return { isValid: true, isPartial: false }; // Empty entry is valid
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: isComplete,
|
|
||||||
isPartial: hasInput && !isComplete,
|
|
||||||
missingFields: {
|
|
||||||
homeowner: entry.homeowner.trim() === '',
|
|
||||||
hoursWorked: entry.hoursWorked.trim() === '',
|
|
||||||
workDescription: entry.workDescription.trim() === ''
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultHomeowners = [
|
|
||||||
'Anderson, 217',
|
|
||||||
'Bakos',
|
|
||||||
'Beckstead, 111',
|
|
||||||
'Bentley, 310',
|
|
||||||
'Best, 103',
|
|
||||||
'Caraway, 132',
|
|
||||||
'Carmichael, M, 216',
|
|
||||||
'Casa Blanca',
|
|
||||||
'Chapin, 106',
|
|
||||||
'Conner, 309',
|
|
||||||
'Cook, 118',
|
|
||||||
'Coyle, 109',
|
|
||||||
'Davis, 114a',
|
|
||||||
'Dimmitt, 213',
|
|
||||||
'Dockery, 502',
|
|
||||||
'Fassett, 303C',
|
|
||||||
'Gypsy Wind',
|
|
||||||
'Hager, 108',
|
|
||||||
'Hanford, 308',
|
|
||||||
'Hitchcox – Clarry, 218',
|
|
||||||
'Hughes, 215',
|
|
||||||
'Kaufman 129 (Blue View)',
|
|
||||||
'Kuchman, 104',
|
|
||||||
'Lockhart, 301A',
|
|
||||||
'Lokey, 136',
|
|
||||||
'McColgan, 312',
|
|
||||||
'Mercurio, 523',
|
|
||||||
'Moff – Dean Elect',
|
|
||||||
'Rogers, 501',
|
|
||||||
'Rusten, 204A',
|
|
||||||
'Ryan, 301B',
|
|
||||||
'Salas, 144',
|
|
||||||
'Sear 128 (Twin Shores)',
|
|
||||||
'Shimp, 517',
|
|
||||||
'Sipprelle, 202',
|
|
||||||
'Trino, 131',
|
|
||||||
'Useppa Fire',
|
|
||||||
'Vogt',
|
|
||||||
'Weinsz, 141',
|
|
||||||
'Wendorf, 306',
|
|
||||||
'White (Rogan)',
|
|
||||||
'Williams, Bob, 140',
|
|
||||||
'Williams, Dan, 137B',
|
|
||||||
'Williamson-Whetstone, 102',
|
|
||||||
'Wilson, George, 516',
|
|
||||||
'Wright, 137A'
|
|
||||||
];
|
|
||||||
const allHomeowners = [...defaultHomeowners, ...customHomeowners];
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedDate,
|
|
||||||
setSelectedDate,
|
|
||||||
weekDays,
|
|
||||||
timeEntries,
|
|
||||||
updateTimeEntry,
|
|
||||||
addTimeEntry,
|
|
||||||
removeTimeEntry,
|
|
||||||
allHomeowners,
|
|
||||||
addCustomHomeowner,
|
|
||||||
customHomeowners,
|
|
||||||
importTimesheet,
|
|
||||||
userName,
|
|
||||||
setUserName,
|
|
||||||
isEntryComplete,
|
|
||||||
hasAnyInput,
|
|
||||||
getEntryValidation
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.jsx'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export const getWeekDays = (date = new Date()) => {
|
|
||||||
const startOfWeek = new Date(date);
|
|
||||||
const day = startOfWeek.getDay();
|
|
||||||
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start
|
|
||||||
startOfWeek.setDate(diff);
|
|
||||||
|
|
||||||
const weekDays = [];
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const day = new Date(startOfWeek);
|
|
||||||
day.setDate(startOfWeek.getDate() + i);
|
|
||||||
weekDays.push(day);
|
|
||||||
}
|
|
||||||
return weekDays;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDate = (date) => {
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatWeekRange = (weekDays) => {
|
|
||||||
if (!weekDays.length) return '';
|
|
||||||
const start = weekDays[0];
|
|
||||||
const end = weekDays[6];
|
|
||||||
|
|
||||||
return `${formatDate(start)} - ${formatDate(end)}, ${end.getFullYear()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDayName = (date) => {
|
|
||||||
return date.toLocaleDateString('en-US', { weekday: 'long' });
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||