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
This commit is contained in:
BizzleBot
2026-02-15 20:16:46 +00:00
parent 68832bd958
commit a7c138add1
88 changed files with 5522 additions and 9159 deletions
-61
View File
@@ -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/
+22
View File
@@ -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
+2 -84
View File
@@ -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
-84
View File
@@ -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.
-32
View File
@@ -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;"]
-20
View File
@@ -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"]
+248 -150
View File
@@ -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>
![Coastal Timesheet App Screenshot](./screenshot.png) <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>
+22
View File
@@ -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
+35
View File
@@ -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"
}
}
+106
View File
@@ -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")
}
+100
View File
@@ -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();
});
+104
View File
@@ -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;
+103
View File
@@ -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,
};
+684
View File
@@ -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;
+210
View File
@@ -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;
+271
View File
@@ -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;
+23
View File
@@ -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;
+309
View File
@@ -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;
+122
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
module.exports = {
sendTimesheetEmail,
verifySmtp,
};
+356
View File
@@ -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,
};
+147
View File
@@ -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,
};
-23
View File
@@ -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
-38
View File
@@ -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:
+55
View File
@@ -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"]
+89
View File
@@ -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
+56
View File
@@ -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;"]
+81
View File
@@ -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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+16
View File
@@ -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>
+28
View File
@@ -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: {},
}, },
} };
+129
View File
@@ -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 };
+90
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+86
View File
@@ -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 };
+79
View File
@@ -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;
+56
View File
@@ -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 };
}
+37
View File
@@ -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 };
}
+38
View File
@@ -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 };
}
+244
View File
@@ -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%;
}
+47
View File
@@ -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>
);
+480
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+311
View File
@@ -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>
);
}
+88
View File
@@ -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')],
};
+20
View File
@@ -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,
},
});
-13
View File
@@ -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>
-45
View File
@@ -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";
}
}
-6654
View File
File diff suppressed because it is too large Load Diff
-31
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-76
View File
@@ -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;
-90
View File
@@ -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>
);
}
-56
View File
@@ -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;
-182
View File
@@ -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;
-90
View File
@@ -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;
-135
View File
@@ -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;
-164
View File
@@ -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;
-162
View File
@@ -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;
-9
View File
@@ -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
-67
View File
@@ -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;
-19
View File
@@ -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;
-110
View File
@@ -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;
-159
View File
@@ -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;
-268
View File
@@ -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;
-21
View File
@@ -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 };
};
-240
View File
@@ -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
};
};
-13
View File
@@ -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;
}
-10
View File
@@ -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>,
)
-33
View File
@@ -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' });
};
-12
View File
@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})