Coastal Timesheet v2 — full feature buildout

Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
This commit is contained in:
BizzleBot
2026-02-16 10:01:39 +00:00
commit f718a76153
65 changed files with 6756 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# Coastal Timesheet v2 — Expert Panel Audit & Fix Task
## App Location
- Frontend source: `/opt/code-collab/projects/active/ba94ec47/v2/frontend/`
- Backend source: `/opt/code-collab/projects/active/ba94ec47/v2/backend/`
- Docker: `/opt/code-collab/projects/active/ba94ec47/v2/docker/`
- Live at: `http://100.94.106.120:8080` (Tailscale)
- Admin login: `admin@coastal.com` / `CoastalAdmin2026!`
## Critical Bugs to Fix
### Bug 1: Timezone causes wrong day names (CRITICAL)
**File:** `frontend/src/components/DayCard.jsx` and `frontend/src/pages/Timesheet.jsx`
**Problem:** `new Date("2026-02-10")` parses as UTC midnight. In US timezones, `getDay()` returns previous day (Sunday instead of Monday).
**Fix:** Use `new Date(date + 'T00:00:00')` (local) or split the date string and construct manually. Apply everywhere dates are parsed from `YYYY-MM-DD` strings.
**Also fix in:** `WeekNavigator.jsx`, `History.jsx`, anywhere `new Date(dateString)` is used with YYYY-MM-DD format.
### Bug 2: Pre-filled entries can't be edited or deleted
**Problem:** User reports entries exist that can't be modified. Likely the `disabled={isLocked}` check is triggering incorrectly, or the auto-save/PUT endpoint has an issue.
**Check:** `Timesheet.jsx` — `isLocked` logic, `handleEntryChange`, `handleDeleteEntry`. Also check if the seed created phantom entries.
### Bug 3: Week label mismatch
**Problem:** Shows "Feb 9 - Feb 15" but starts with Sunday Feb 8. The header says one range but cards show different dates.
**Fix:** Ensure `WeekNavigator` header range and `getWeekDates()` output are consistent — both Mon-Sun.
## Feature Requirements
### Multiple homeowners per day (already partially works)
- The "Add Entry" button exists in DayCard but verify it works end-to-end
- Each day should support unlimited entries (different homeowners, different hours)
- Total hours per day should sum all entries
### Admin Reporting (needs frontend)
Backend already has these endpoints — need admin UI:
- `GET /api/admin/timesheets` — list all employee timesheets with filters
- `GET /api/admin/timesheets/:id` — detail view of specific timesheet
- `GET /api/admin/reports` — reporting with date range, employee, homeowner filters
- `GET /api/admin/reports/pdf` — PDF export
### Admin Panel Enhancements Needed
1. **Employee Timesheet Browser** — Admin can select any employee and see their timesheet for any week
2. **Filters** — by employee, date range, homeowner, status (draft/submitted/approved/rejected)
3. **Bulk actions** — approve/reject multiple timesheets at once
4. **Export** — CSV/PDF export of filtered data
5. **Dashboard summary** — total hours this week/month, pending reviews count, recent submissions
## Quality Standards
- Mobile-first, responsive design
- Dark mode support throughout
- Loading states for all async operations
- Error handling with user-friendly messages
- No console errors
- All dates display correctly in US timezones
- Professional, polished UI suitable for 40+ employees
+243
View File
@@ -0,0 +1,243 @@
# Coastal Timesheet v2 — Backend API Audit Report
**Auditor:** ScriptForge ⚡ (Expert Panel)
**Date:** 2026-02-15
**Backend:** Node.js + Express + Prisma + PostgreSQL
**Container:** `coastal-backend` on port 127.0.0.1:3001
---
## Executive Summary
The backend API is **solid and well-engineered**. All core CRUD endpoints work correctly, authentication/authorization is properly enforced, Zod validation catches malformed input, and the locked-timesheet guard prevents edits to submitted/approved timesheets. The seed file is clean — no phantom entries. There are a few medium-priority issues (no future-date guard, no daily hour cap, JWT dev fallback secrets in code) and a couple of informational notes.
**Overall Grade: B+** — Production-worthy with minor hardening needed.
---
## Test Results Summary
| # | Test | Endpoint | Result | Status |
|---|------|----------|--------|--------|
| 1 | Health check | `GET /api/health` | `{"status":"ok"}` | ✅ PASS |
| 2 | Admin login | `POST /api/auth/login` | Returns JWT + user object | ✅ PASS |
| 3 | Get entries (locked week) | `GET /api/entries?week=2026-02-10` | 200, 2 entries, `isLocked:true` | ✅ PASS |
| 4 | Create entry (locked week) | `POST /api/entries` | 403 — correctly blocked | ✅ PASS |
| 5 | Create entry (open week) | `POST /api/entries` | 201 — entry created | ✅ PASS |
| 6 | Update entry | `PUT /api/entries/:id` | 200 — hours & description updated | ✅ PASS |
| 7 | Delete entry | `DELETE /api/entries/:id` | 200 — entry removed | ✅ PASS |
| 8 | Delete non-existent | `DELETE /api/entries/:id` | 404 — `Entry not found` | ✅ PASS |
| 9 | List timesheets (admin) | `GET /api/admin/timesheets` | 200, pagination working | ✅ PASS |
| 10 | Filter timesheets | `GET /api/admin/timesheets?status=submitted` | 200, empty (none submitted) | ✅ PASS |
| 11 | Reports (no filter) | `GET /api/admin/reports` | 200, summary + byUser + byHomeowner | ✅ PASS |
| 12 | Reports (date range) | `GET /api/admin/reports?from=...&to=...` | 200, filtered correctly | ✅ PASS |
| 13 | Timesheet detail | `GET /api/admin/timesheets/:id` | 200, full entry breakdown | ✅ PASS |
| 14 | Bad validation (all fields) | `POST /api/entries` bad data | 400, 4 error details | ✅ PASS |
| 15 | No auth token | `GET /api/entries` | 401 — `Access token required` | ✅ PASS |
| 16 | Invalid auth token | `GET /api/entries` | 401 — `Invalid access token` | ✅ PASS |
| 17 | Non-existent entry update | `PUT /api/entries/00000...` | 404 — `Entry not found` | ✅ PASS |
| 18 | Zero hours | `POST /api/entries` hours=0 | 400 — `Hours must be positive` | ✅ PASS |
| 19 | Over 24 hours | `POST /api/entries` hours=25 | 400 — `Hours cannot exceed 24` | ✅ PASS |
| 20 | Edit approved entry | `PUT /api/entries/:id` | 403 — correctly blocked | ✅ PASS |
| 21 | Delete approved entry | `DELETE /api/entries/:id` | 403 — correctly blocked | ✅ PASS |
| 22 | Auth me | `GET /api/auth/me` | 200, user profile | ✅ PASS |
| 23 | Timesheet view | `GET /api/timesheets?week=...` | 200, status + entries | ✅ PASS |
| 24 | Timesheet history | `GET /api/timesheets/history` | 200, list with approver | ✅ PASS |
| 25 | Multiple entries per day | `POST /api/entries` ×2 | 201 both, confirmed on GET | ✅ PASS |
| 26 | Future date accepted | `POST /api/entries` date=2026-12-25 | 201 — **no guard** | ⚠️ WARNING |
| 27 | 48h on same day | `POST /api/entries` 24h ×2 | Both created — **no cap** | ⚠️ WARNING |
| 28 | Inactive homeowner | `POST /api/entries` | 400 — `Invalid or inactive homeowner` | ✅ PASS |
| 29 | Empty body POST | `POST /api/entries` `{}` | 400, all fields required | ✅ PASS |
| 30 | Empty body PUT | `PUT /api/entries/:id` `{}` | 200, no-op update (returned unchanged) | ✅ PASS |
| 31 | SQL injection in query | `?week=2026-02-10';DROP...` | 400 — regex validation blocked | ✅ PASS |
| 32 | Refresh token flow | `POST /api/auth/refresh` | 200, new token pair | ✅ PASS |
| 33 | PDF generation | `GET /api/admin/reports/pdf` | 200, valid PDF (4090 bytes) | ✅ PASS |
| 34 | PDF missing params | `GET /api/admin/reports/pdf` no query | 400 — proper error | ✅ PASS |
| 35 | Submit timesheet | `POST /api/timesheets/submit` | 200, status=submitted, entries linked | ✅ PASS |
| 36 | Edit after submit | `PUT /api/entries/:id` | 403 — blocked | ✅ PASS |
| 37 | Edit after rejection | `PUT /api/entries/:id` | 200 — unlocked correctly | ✅ PASS |
| 38 | CORS (evil origin) | `Origin: http://evil.com` | 403 — blocked | ✅ PASS |
| 39 | Week boundary (Sunday input) | `?week=2026-02-15` (Sunday) | weekStart=2026-02-09 (correct Monday) | ✅ PASS |
| 40 | Homeowners list | `GET /api/homeowners` | 200, 47 homeowners | ✅ PASS |
| 41 | Admin users list | `GET /api/admin/users` | 200, 2 users | ✅ PASS |
**Pass: 37/41 | Warnings: 2 | Informational: 2**
---
## Seed.js Analysis — No Phantom Entries
**File:** `backend/prisma/seed.js`
The seed script creates **only**:
1. One admin user (`admin@coastal.com`, role `super_admin`)
2. 47 homeowners (using `upsert` — idempotent)
**It does NOT create any `TimeEntry`, `Timesheet`, or `TimesheetEntry` records.**
The 2 entries found in the database (date `2026-02-09` "Monday work" and `2026-02-10` "Tuesday work") were created **after seeding** — their `createdAt` timestamps (`2026-02-15T19:17:44.997Z`) are well after seed time, and they were created programmatically (likely during prior testing/demo setup). They are not phantom entries from the seed.
**Verdict: Seed is clean. ✅**
---
## Issues Found
### 🟡 MEDIUM: No Future Date Validation (entries.js)
**Impact:** Users can create timesheet entries for any date in the future (tested: 2026-12-25, 10 months out). This allows pre-filling timesheets for work not yet performed.
**Location:** `POST /api/entries` and `PUT /api/entries/:id` in `backend/src/routes/entries.js`
**Recommendation:** Add server-side validation in `createEntrySchema`:
```js
// In validation.js — add to createEntrySchema
date: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
.refine(val => new Date(val + 'T00:00:00Z') <= new Date(), {
message: 'Cannot create entries for future dates',
}),
```
Or allow up to end of current week only.
---
### 🟡 MEDIUM: No Per-Day Total Hour Cap (entries.js)
**Impact:** A user can add unlimited entries per day with up to 24 hours each. Two entries at 24h each = 48h on one day. No server-side aggregate check.
**Location:** `POST /api/entries` in `backend/src/routes/entries.js`
**Recommendation:** Before creating an entry, sum existing hours for that user + date. Reject if `existingHours + newEntry.hoursWorked > 24`:
```js
const existing = await req.prisma.timeEntry.aggregate({
where: { userId: req.user.id, date: new Date(date + 'T00:00:00Z') },
_sum: { hoursWorked: true },
});
const totalForDay = parseFloat(existing._sum.hoursWorked || 0) + hoursWorked;
if (totalForDay > 24) {
return res.status(400).json({
error: `Total hours for this day would be ${totalForDay}. Maximum is 24.`,
});
}
```
---
### 🟢 LOW: JWT Dev Fallback Secrets in Code (auth.js)
**Impact:** If `JWT_SECRET` / `JWT_REFRESH_SECRET` env vars are missing, the code falls back to hardcoded strings (`'dev-jwt-secret-change-me'`). In production Docker, these ARE set via env vars (confirmed), so this is not an active risk — but the fallback should ideally throw an error in production instead of silently using a weak secret.
**Location:** `backend/src/middleware/auth.js`, lines 3-4
**Current:**
```js
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me';
```
**Recommended:**
```js
const JWT_SECRET = process.env.JWT_SECRET || (() => {
if (process.env.NODE_ENV === 'production') throw new Error('JWT_SECRET required');
return 'dev-jwt-secret-change-me';
})();
```
---
### 🟢 LOW: Refresh Token Reuse Detection Race Condition
**Observation:** The auth code implements refresh token rotation (good), but rapid successive calls can bypass the reuse detection. This is a known limitation of simple token rotation and not a critical flaw — in practice, an attacker would need to intercept the token in the exact window.
**Mitigation (if desired):** Store a token family ID and invalidate all tokens in the family on reuse detection.
---
### ℹ️ INFO: `PUT /api/entries/:id` with Empty Body Returns 200
Sending `{}` to the update endpoint returns 200 with the entry unchanged. This is technically correct (no fields to update → no-op) but could confuse clients expecting a 400. The `updateEntrySchema` has all fields optional, which is the intended design for partial updates.
**Verdict:** Acceptable behavior. No action needed.
---
### ℹ️ INFO: Timesheet History `totalHours` is Always `null`
**Location:** `GET /api/timesheets/history` in `backend/src/routes/timesheets.js`
The history response includes `totalHours: null` with a comment `// Could aggregate if needed`. The frontend may need this for displaying summaries.
**Recommendation:** Either aggregate hours or remove the field to avoid confusion:
```js
// Option A: populate it
const entryLinks = await prisma.timesheetEntry.findMany({
where: { timesheetId: ts.id },
include: { timeEntry: { select: { hoursWorked: true } } },
});
const totalHours = entryLinks.reduce((s, l) => s + parseFloat(l.timeEntry.hoursWorked), 0);
```
---
## What's Working Well
1. **Zod Validation** — All input is validated with clear error messages. Regex on dates, UUID on IDs, ranges on hours. SQL injection is impossible through validated paths.
2. **Timesheet Locking** — The submit → lock → reject → unlock flow works perfectly. Entries in submitted/approved weeks are properly guarded on all mutation endpoints (POST, PUT, DELETE).
3. **Authentication & Authorization** — Bearer token auth with proper 401/403 distinctions. Admin routes require `admin` or `super_admin` role. Token refresh with rotation is implemented.
4. **CORS** — Properly configured allowlist. `Origin: evil.com` gets 403. Curl (no origin) is allowed for API tooling.
5. **Week Calculation** — `getWeekMonday()` correctly uses UTC throughout. Sunday input (2026-02-15) correctly resolves to Monday (2026-02-09). No timezone drift bugs in the backend.
6. **Multiple Entries Per Day** — Fully supported. No unique constraint on (userId, date, homeownerId), allowing multiple jobs per homeowner or multiple homeowners per day.
7. **Inactive Homeowner Guard** — Creating entries for deactivated homeowners is properly rejected with 400.
8. **PDF Generation** — Works end-to-end. Returns valid PDF with correct content-type headers.
9. **Pagination** — Admin timesheets endpoint supports `page` + `limit` with `total` and `totalPages` in response.
10. **Helmet + Security Headers** — `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection` all set via nginx.
---
## Architecture Notes
| Layer | Tech | Notes |
|-------|------|-------|
| Runtime | Node.js v22.22 | Inside Docker |
| Framework | Express.js | With helmet, cors, rate-limit |
| ORM | Prisma | PostgreSQL 16 Alpine |
| Validation | Zod | Middleware pattern (`validateBody`, `validateQuery`) |
| Auth | JWT (HS256) | 15m access + 7d refresh, rotation |
| Rate Limit | express-rate-limit | 5 attempts / 15 min on auth |
| Proxy | Nginx | `/api/*` → backend, `/*` → frontend SPA |
| PDF | Custom (pdf.js) | Generates valid PDF documents |
---
## Relationship to AUDIT-TASK.md Bugs
### Bug 1 (Timezone wrong day names) — **Backend is NOT affected**
The backend uses `'T00:00:00Z'` (UTC) consistently in `getWeekMonday()` and all date construction. The timezone bug is **frontend-only** (using `new Date("YYYY-MM-DD")` which parses as UTC midnight, then `getDay()` in local timezone).
### Bug 2 (Pre-filled entries can't be edited) — **Backend works correctly**
- Unlocked entries: PUT/DELETE both work ✅
- Locked entries: PUT/DELETE properly return 403 ✅
- After rejection: entries become editable again ✅
The "can't edit" issue is likely **frontend** — the `isLocked` / `disabled` logic in `DayCard.jsx` or `Timesheet.jsx` may not be checking the right condition, or may not be updating state after rejection.
### Bug 3 (Week label mismatch) — **Backend returns correct data**
`weekStart: 2026-02-09` (Monday) and `weekEnd: 2026-02-15` (Sunday) are correct. The mismatch is a **frontend rendering issue** in `WeekNavigator.jsx`.
---
## Recommended Priority Actions
1. **Add future-date guard** — 30 min fix in `validation.js` (🟡 MEDIUM)
2. **Add per-day hour cap** — 1 hour fix in `entries.js` (🟡 MEDIUM)
3. **Populate `totalHours` in history** — 30 min fix in `timesheets.js` (ℹ️ LOW)
4. **Fail-fast on missing JWT secrets in production** — 10 min fix in `auth.js` (🟢 LOW)
+63
View File
@@ -0,0 +1,63 @@
# Changelog
All notable changes to Coastal Timesheet will be documented in this file.
## [2.1.0] - 2026-02-15
### Added
- **Copy Previous Week** — One-tap button to duplicate last week's entries into the current week. Shifts dates automatically, skips deactivated homeowners. Shows on empty weeks only.
- **Bulk Approve / Reject** — Admin Reviews tab now supports multi-select with "Select All" checkbox and bulk action buttons. Process 40+ timesheets in one click instead of one-by-one.
- **Overtime Tracking** — New admin "Overtime" tab with per-employee weekly breakdown, total/regular/OT summary cards, and date range filtering. Employee timesheet page shows real-time amber warning banner when hours exceed 40h/week (FLSA threshold).
- **Homeowner Filter on Reports** — Admin Reports tab now includes a homeowner dropdown filter to see all time logged at a specific property.
- **Homeowner Address Field** — Homeowners now have a separate address field. House numbers split from names into dedicated address column.
- **Homeowner Edit/Search** — Full inline editing for homeowner name and address, search bar, toggle active/inactive with "Show inactive" filter.
- **Employee Edit/Delete** — Admin Users tab now has role dropdown (employee/admin), deactivate button (soft-delete preserving history), and reactivate option.
- **Reopen Timesheet** — `PUT /api/admin/timesheets/:id/reopen` endpoint allows admins to unlock approved/rejected timesheets for corrections.
- **Timesheet History** — `GET /api/timesheets/history` endpoint returns all user timesheets for the History page.
- **Public Homeowners API** — `GET /api/homeowners` endpoint for authenticated users (used by timesheet entry form).
### Fixed
- **Timezone bug** — `new Date("YYYY-MM-DD")` parsed as UTC, causing wrong day names in US timezones. All date parsing now uses local time.
- **CORS policy violation** — Added Tailscale IP to allowed CORS origins.
- **Approve button 400 error** — Frontend now sends `{}` body on approve (validation required an object).
- **PDF download silent failure** — Frontend was using `timesheet.id` but API returns `timesheetId`. Fixed field mapping.
- **Auto-save errors on incomplete entries** — Auto-save now only fires when all required fields (homeowner, hours, description) are filled. Incomplete entries show amber border indicator.
- **History page crash** — `.map is not a function` when API returned object instead of array. Added dedicated history endpoint.
- **Rate limit too aggressive** — Auth endpoint bumped from 5 to 50 attempts per 15 min for development.
### API Endpoints Added
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/entries/copy-week` | Copy entries from one week to another |
| POST | `/api/admin/timesheets/bulk-approve` | Approve multiple timesheets |
| POST | `/api/admin/timesheets/bulk-reject` | Reject multiple timesheets |
| GET | `/api/admin/overtime` | Overtime report with filters |
| GET | `/api/timesheets/overtime` | Employee's own overtime for a week |
| PUT | `/api/admin/timesheets/:id/reopen` | Reopen locked timesheet |
| DELETE | `/api/admin/users/:id` | Deactivate employee (soft-delete) |
| GET | `/api/timesheets/history` | All user timesheets |
| GET | `/api/homeowners` | Public homeowner list |
## [2.0.0] - 2026-02-15
### Added
- Complete rewrite from static HTML to production-grade stack
- React 18 + Vite + Tailwind CSS (mobile-first design)
- Express + Prisma + PostgreSQL backend
- JWT authentication with role-based access (employee, admin, super_admin)
- Weekly Mon–Sun timesheets with auto-save (800ms debounce)
- Multiple homeowner entries per day
- Submit → Approve/Reject workflow
- Server-side PDF generation (@react-pdf/renderer)
- SMTP email integration with branded HTML templates
- Admin panel with employee/homeowner management
- Reporting with employee, status, and date range filters
- Dark mode (system-aware + manual toggle)
- Docker Compose single-command deployment
- Non-root containers, Helmet security headers, bcrypt, Zod validation
- Rate limiting on auth endpoints
- 46 homeowners seeded from v1 data
+412
View File
@@ -0,0 +1,412 @@
# Coastal Timesheet v2 — New Feature Recommendations
**Author:** ScriptForge ⚡ (Expert Panel — Scripting & Testing)
**Date:** 2025-07-18
**Scope:** Backend API features to take this from "solid timesheet app" to "best-in-class workforce management tool"
---
## Current State Summary
The v2 backend is well-built: Express + Prisma + PostgreSQL with JWT auth, Zod validation, weekly timesheets (Mon–Sun), multi-entry-per-day, auto-save, submit/approve/reject/reopen workflow, PDF generation, email delivery, admin panel with employee/homeowner CRUD, and filtered reporting. **37/41 audit tests pass.**
What's missing is everything that happens *around* the timesheet — the operational glue that a 40+ person island contracting company actually needs to run payroll, stay accountable, and scale without drowning in admin work.
---
## Feature 1: Audit Trail / Activity Log
**Priority:** 🔴 HIGH
**Complexity:** Medium (2–3 days)
**Why:** Right now, there is zero record of *who changed what and when*. An admin approves a timesheet — no log. An admin reopens an approved timesheet and changes entries — no log. An employee edits hours — no log. For a company processing payroll for 40+ workers, this is a liability gap.
### Schema Addition
```prisma
model AuditLog {
id String @id @default(uuid())
userId String @map("user_id")
action String // CREATE_ENTRY, UPDATE_ENTRY, DELETE_ENTRY, SUBMIT_TIMESHEET,
// APPROVE_TIMESHEET, REJECT_TIMESHEET, REOPEN_TIMESHEET,
// UPDATE_USER, DEACTIVATE_USER, etc.
entityType String @map("entity_type") // TimeEntry, Timesheet, User, Homeowner
entityId String @map("entity_id")
oldValues Json? @map("old_values") // snapshot before change
newValues Json? @map("new_values") // snapshot after change
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([entityType, entityId])
@@index([userId])
@@index([createdAt])
@@map("audit_logs")
}
```
### API Endpoints
```
GET /api/admin/audit-logs?entityType=&entityId=&userId=&from=&to=&action=&page=&limit=
GET /api/admin/audit-logs/:entityId/history (full change history for one record)
```
### Implementation Notes
- Create a `logAudit(req, action, entityType, entityId, oldValues, newValues)` utility
- Call it as middleware or explicitly after every mutation in entries, timesheets, admin routes
- Store `req.ip` and `req.headers['user-agent']` for forensics
- Old/new values as JSON snapshots enable "diff view" on the frontend later
- Add retention policy: auto-delete logs older than 2 years via a cron job or Prisma middleware
---
## Feature 2: Overtime Calculation Engine
**Priority:** 🔴 HIGH
**Complexity:** Medium (2–3 days)
**Why:** The app tracks `hoursWorked` per entry but has zero concept of overtime. A contracting company with 40+ employees absolutely needs to know who's hitting 40h/week, what the OT breakdown is, and what the payroll cost looks like before cutting checks. This is also a legal compliance concern.
### Schema Addition
```prisma
model OvertimeRule {
id String @id @default(uuid())
name String @unique // "Standard", "Holiday", etc.
weeklyThreshold Decimal @default(40) @map("weekly_threshold") @db.Decimal(5,2)
dailyThreshold Decimal? @map("daily_threshold") @db.Decimal(5,2) // optional: some states require daily OT
overtimeRate Decimal @default(1.5) @map("overtime_rate") @db.Decimal(3,2)
doubleTimeRate Decimal? @map("double_time_rate") @db.Decimal(3,2) // e.g., over 12h/day
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("overtime_rules")
}
```
### API Endpoints
```
GET /api/admin/overtime-rules
POST /api/admin/overtime-rules
PUT /api/admin/overtime-rules/:id
GET /api/admin/reports/overtime?from=&to=&userId= → computed OT breakdown
GET /api/timesheets/overtime?week=YYYY-MM-DD → employee sees their own OT for the week
```
### Implementation Notes
- The OT calculation is a **read-time computation**, not stored — keeps source of truth clean
- `GET /reports/overtime` returns per-employee: `{ regularHours, overtimeHours, doubleTimeHours, totalHours }`
- Compute from `TimeEntry` rows grouped by userId + week, applying the active `OvertimeRule`
- Support both weekly threshold (federal FLSA: >40h) and daily threshold (California: >8h/day)
- The report endpoint should be able to span multiple weeks for payroll periods
- Add `hourlyRate` field to `User` model (optional) so the report can also compute `estimatedPay`
---
## Feature 3: Bulk Timesheet Operations
**Priority:** 🔴 HIGH
**Complexity:** Low–Medium (1–2 days)
**Why:** An admin managing 40+ employees who submit timesheets weekly is looking at potentially 40 approval clicks every Monday morning. The current API handles one timesheet at a time — approve, reject, reopen are all single-ID operations. This doesn't scale.
### API Endpoints
```
POST /api/admin/timesheets/bulk-approve
Body: { timesheetIds: string[], notes?: string }
Response: { approved: number, failed: { id, reason }[] }
POST /api/admin/timesheets/bulk-reject
Body: { timesheetIds: string[], notes: string }
Response: { rejected: number, failed: { id, reason }[] }
POST /api/admin/timesheets/bulk-reopen
Body: { timesheetIds: string[] }
Response: { reopened: number, failed: { id, reason }[] }
```
### Implementation Notes
- Use `prisma.$transaction()` for atomicity — either all succeed or return partial results with failure reasons
- Validate each timesheet's current status before acting (can't approve a draft, can't reject an approved, etc.)
- Return a summary with individual failure reasons so the admin knows exactly what happened
- Cap batch size at 100 to prevent abuse
- Wire into audit log (Feature 1): one audit entry per timesheet, not one for the batch
---
## Feature 4: Notification System (Webhook + Email Triggers)
**Priority:** 🟡 MEDIUM
**Complexity:** Medium (3–4 days)
**Why:** The app already has email capability (used for emailing PDFs), but there are no *automatic* notifications. When an employee submits a timesheet, the admin doesn't know until they manually check. When a timesheet is rejected, the employee doesn't know until they manually check. For a crew of 40+ people on an island, this creates bottlenecks.
### Schema Addition
```prisma
model NotificationPreference {
id String @id @default(uuid())
userId String @map("user_id")
event String // TIMESHEET_SUBMITTED, TIMESHEET_APPROVED, TIMESHEET_REJECTED,
// REMINDER_SUBMIT, REMINDER_APPROVE
channel String @default("email") // email, webhook (future: push, sms)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@unique([userId, event, channel])
@@map("notification_preferences")
}
```
### Triggered Events
| Event | Who Gets Notified | Trigger Point |
|-------|-------------------|---------------|
| `TIMESHEET_SUBMITTED` | All admins | `POST /api/timesheets/submit` |
| `TIMESHEET_APPROVED` | The employee | `PUT /api/admin/timesheets/:id/approve` |
| `TIMESHEET_REJECTED` | The employee (with rejection notes) | `PUT /api/admin/timesheets/:id/reject` |
| `REMINDER_SUBMIT` | Employees with no submission for current week | Cron: Sunday 6 PM |
| `REMINDER_APPROVE` | Admins with pending submissions | Cron: Monday 9 AM |
### API Endpoints
```
GET /api/notifications/preferences → user's notification settings
PUT /api/notifications/preferences → update preferences
POST /api/admin/notifications/send-reminders → manually trigger submission reminders
```
### Implementation Notes
- Build a `NotificationService` class that the route handlers call: `notifyService.emit('TIMESHEET_SUBMITTED', { timesheet, user })`
- The service checks preferences, then dispatches via email (already have SMTP util) or webhook
- Cron-based reminders use a lightweight scheduler (e.g., `node-cron`) or a separate script run by system cron
- Keep notifications async (fire-and-forget with error logging) so they never slow down the API response
- Future: add webhook URL support per-user so third-party systems (Slack, accounting) can subscribe
---
## Feature 5: CSV/Excel Export for Payroll Integration
**Priority:** 🟡 MEDIUM
**Complexity:** Low (1 day)
**Why:** The app has PDF export, which is great for records and signatures — but nobody imports a PDF into QuickBooks, Gusto, or ADP. Payroll processing for 40+ employees requires **machine-readable export** in CSV or XLSX format, ideally matching common payroll import templates.
### API Endpoints
```
GET /api/admin/reports/export?format=csv&from=&to=&userId=&homeownerId=
GET /api/admin/reports/export?format=xlsx&from=&to=&groupBy=user|homeowner|week
GET /api/timesheets/:id/export?format=csv → single timesheet export
```
### Export Formats
**Payroll CSV** (one row per employee per week):
```csv
Employee Name,Email,Week Starting,Regular Hours,Overtime Hours,Total Hours,Status,Approved By,Approved Date
John Smith,john@coastal.com,2026-02-09,38.50,0.00,38.50,approved,Admin,2026-02-16
```
**Detailed CSV** (one row per time entry):
```csv
Date,Employee,Homeowner,Hours,Description,Timesheet Status
2026-02-09,John Smith,Johnson Residence,4.50,Deck repair,approved
```
**Homeowner Billing CSV** (for invoicing homeowners):
```csv
Homeowner,Address,Total Hours,Employee,Date,Description,Hours
Johnson Residence,123 Beach Rd,12.50,John Smith,2026-02-09,Deck repair,4.50
```
### Implementation Notes
- Use `csv-stringify` (lightweight, no binary deps) for CSV
- Use `exceljs` for XLSX if needed (supports formatting, multiple sheets)
- The `groupBy` parameter controls whether rows are aggregated by user, homeowner, or week
- Stream large exports using `res.write()` + `csv-stringify`'s streaming API to avoid loading everything into memory
- Add `Content-Disposition: attachment; filename="coastal-payroll-2026-02-09.csv"` headers
- This pairs with the overtime engine (Feature 2): the payroll CSV should include regular/OT hour splits
---
## Feature 6: Geolocation Check-In/Check-Out
**Priority:** 🟡 MEDIUM
**Complexity:** Medium–High (3–5 days)
**Why:** Workers travel between homeowner properties on an island. Manual hour entry is trust-based. Geolocation check-in/out provides accountability — not as a punitive surveillance tool, but as a convenience (auto-populate which homeowner they're at) and a verification layer (admin can see that claimed hours at a property match GPS presence).
### Schema Additions
```prisma
model HomeownerLocation {
id String @id @default(uuid())
homeownerId String @unique @map("homeowner_id")
latitude Decimal @db.Decimal(10, 7)
longitude Decimal @db.Decimal(10, 7)
radiusMeters Int @default(200) @map("radius_meters") // geofence radius
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
homeowner Homeowner @relation(fields: [homeownerId], references: [id])
@@map("homeowner_locations")
}
model CheckIn {
id String @id @default(uuid())
userId String @map("user_id")
homeownerId String? @map("homeowner_id") // auto-matched or manual
latitude Decimal @db.Decimal(10, 7)
longitude Decimal @db.Decimal(10, 7)
accuracy Decimal? @db.Decimal(8, 2) // GPS accuracy in meters
type String // CHECK_IN or CHECK_OUT
autoMatched Boolean @default(false) @map("auto_matched") // was homeowner auto-detected?
timeEntryId String? @map("time_entry_id") // linked after checkout
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, createdAt])
@@map("check_ins")
}
```
### API Endpoints
```
POST /api/checkin
Body: { latitude, longitude, accuracy?, homeownerId? }
Response: { checkInId, autoMatchedHomeowner?, timestamp }
POST /api/checkout
Body: { latitude, longitude, accuracy? }
Response: { checkOutId, duration, suggestedEntry: { homeownerId, hoursWorked } }
GET /api/checkins?date=YYYY-MM-DD → employee's check-in history
GET /api/admin/checkins?userId=&date=&homeownerId= → admin view of all check-ins
PUT /api/admin/homeowners/:id/location
Body: { latitude, longitude, radiusMeters? } → set homeowner geofence
```
### Implementation Notes
- On check-in, use Haversine formula to find the nearest `HomeownerLocation` within `radiusMeters` — auto-populate `homeownerId`
- On check-out, calculate duration since last check-in and suggest a `TimeEntry` with pre-filled hours
- The frontend (mobile-first) calls `navigator.geolocation.getCurrentPosition()` and sends coords to the API
- **Privacy consideration:** Store location only at check-in/out moments, not continuous tracking
- Geolocation is *supplementary*, not mandatory — employees can still manually create entries without checking in
- Admin reports can flag discrepancies: "claimed 8h at Johnson property but only checked in for 3h"
- Island properties are spread out enough that a 200m default radius should avoid overlap
---
## Feature 7: Rate Limiting & API Throttling Improvements
**Priority:** 🟢 LOW
**Complexity:** Low (0.5–1 day)
**Why:** The current rate limiter only covers auth endpoints (50 req / 15 min). All other API routes are unlimited. With 40+ employees hitting auto-save on the frontend, plus admin bulk operations, plus PDF generation (CPU-intensive), the API needs tiered rate limiting to stay healthy under load.
### Implementation
```js
// Tiered rate limiters
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }); // tighten from 50 → 10
const apiLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: 120 }); // general: 120/min per IP
const pdfLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: 10 }); // PDF: 10/min (CPU-heavy)
const bulkLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: 5 }); // bulk ops: 5/min
// Apply
app.use('/api/auth', authLimiter);
app.use('/api/', apiLimiter);
app.use('/api/timesheets/:id/pdf', pdfLimiter);
app.use('/api/admin/reports/pdf', pdfLimiter);
app.use('/api/admin/timesheets/bulk-*', bulkLimiter);
```
### Additional Improvements
- Add `Retry-After` header on 429 responses (already supported by `express-rate-limit`)
- Use `req.user.id` as the key generator for authenticated routes (instead of IP) — prevents one user from burning the limit for everyone behind a shared NAT
- Add a `/api/health` rate limit (currently unlimited — potential for monitoring abuse)
- Consider Redis-backed rate limiting (`rate-limit-redis`) if the app scales to multiple backend instances
---
## Feature 8: Timesheet Locking with Payroll Period Cutoffs
**Priority:** 🟢 LOW
**Complexity:** Low–Medium (1–2 days)
**Why:** The current locking mechanism is per-timesheet (submitted/approved = locked). But there's no *global* time-based cutoff. An employee could go back and edit timesheets from 3 months ago as long as they were never submitted. A payroll admin needs hard cutoff dates: "Everything before Feb 1st is frozen — payroll has been run."
### Schema Addition
```prisma
model PayrollPeriod {
id String @id @default(uuid())
name String // "Jan 2026 Payroll", "Pay Period 2026-W06"
startDate DateTime @map("start_date") @db.Date
endDate DateTime @map("end_date") @db.Date
lockedAt DateTime? @map("locked_at") // null = still open
lockedBy String? @map("locked_by")
processedAt DateTime? @map("processed_at") // when payroll was actually run
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("payroll_periods")
}
```
### API Endpoints
```
GET /api/admin/payroll-periods → list all periods
POST /api/admin/payroll-periods → create a new period
PUT /api/admin/payroll-periods/:id/lock → lock period (freezes all entries in range)
PUT /api/admin/payroll-periods/:id/unlock → unlock for corrections
DELETE /api/admin/payroll-periods/:id → only if not yet locked
```
### Implementation Notes
- Modify the entry mutation guards in `entries.js`: before checking timesheet status, also check if the entry's date falls within a locked payroll period
- Guard logic: `if (payrollPeriod && payrollPeriod.lockedAt) → 403 "Payroll period is locked"`
- Locking a period should auto-approve all submitted timesheets within that date range (or warn about un-submitted ones)
- The lock endpoint should return a summary: `{ locked: true, timesheetsApproved: 38, timesheetsStillDraft: 2, warning: "2 employees haven't submitted" }`
- Pairs with CSV export (Feature 5): `GET /api/admin/payroll-periods/:id/export?format=csv`
---
## Priority Summary
| # | Feature | Priority | Complexity | Effort |
|---|---------|----------|------------|--------|
| 1 | Audit Trail / Activity Log | 🔴 HIGH | Medium | 2–3 days |
| 2 | Overtime Calculation Engine | 🔴 HIGH | Medium | 2–3 days |
| 3 | Bulk Timesheet Operations | 🔴 HIGH | Low–Med | 1–2 days |
| 4 | Notification System | 🟡 MEDIUM | Medium | 3–4 days |
| 5 | CSV/Excel Payroll Export | 🟡 MEDIUM | Low | 1 day |
| 6 | Geolocation Check-In/Out | 🟡 MEDIUM | Med–High | 3–5 days |
| 7 | Rate Limiting Improvements | 🟢 LOW | Low | 0.5–1 day |
| 8 | Payroll Period Cutoffs | 🟢 LOW | Low–Med | 1–2 days |
**Total estimated effort: 14–21 days** for all 8 features.
**Recommended build order:** 3 → 1 → 5 → 2 → 4 → 8 → 7 → 6
Rationale: Start with bulk ops (instant admin productivity win, lowest effort of the HIGHs), then audit trail (compliance foundation everything else builds on), then CSV export (unblocks payroll integration immediately), then overtime (builds on the export), then notifications (quality-of-life), then payroll periods (builds on audit + overtime), then rate limiting (quick hardening), and finally geolocation (biggest lift, most optional).
---
*ScriptForge ⚡ — "If it doesn't have an audit log, it didn't happen."*
+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"
}
}
@@ -0,0 +1,98 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'warehouse',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"phone" TEXT,
"email" TEXT,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Package" (
"id" TEXT NOT NULL,
"trackingCode" TEXT NOT NULL,
"description" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"itemNotes" TEXT,
"deliveryNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Package_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustodyEvent" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT NOT NULL,
"photoUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustodyEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Package_trackingCode_key" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "Package_clientId_idx" ON "Package"("clientId");
-- CreateIndex
CREATE INDEX "Package_status_idx" ON "Package"("status");
-- CreateIndex
CREATE INDEX "Package_trackingCode_idx" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "CustodyEvent_packageId_idx" ON "CustodyEvent"("packageId");
-- CreateIndex
CREATE INDEX "CustodyEvent_userId_idx" ON "CustodyEvent"("userId");
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1 @@
{"provider":"postgresql"}
+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,
};
+863
View File
@@ -0,0 +1,863 @@
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' });
}
});
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
router.post('/timesheets/bulk-approve', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
if (timesheetIds.length > 100) {
return res.status(400).json({ error: 'Maximum 100 timesheets per batch' });
}
const results = { approved: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}", expected "submitted"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'approved', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || null },
});
results.approved++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk approve error:', err);
res.status(500).json({ error: 'Failed to bulk approve' });
}
});
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
router.post('/timesheets/bulk-reject', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
const results = { rejected: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'rejected', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || 'Rejected' },
});
results.rejected++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk reject error:', err);
res.status(500).json({ error: 'Failed to bulk reject' });
}
});
// ─────────────── 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' });
}
});
// ═══════════════════════════════════════════════════════════════
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
router.delete('/users/:id', async (req, res) => {
try {
const { id } = req.params;
if (id === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const user = await req.prisma.user.findUnique({ where: { id } });
if (!user) return res.status(404).json({ error: 'User not found' });
// Soft-delete: deactivate instead of hard delete (preserves timesheet history)
await req.prisma.user.update({ where: { id }, data: { isActive: false } });
res.json({ message: 'User deactivated', id });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Failed to delete 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' });
}
});
// ═══════════════════════════════════════════════════════════════
// OVERTIME
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const { from, to, userId } = req.query;
const weeklyThreshold = parseFloat(req.query.threshold || '40');
const overtimeRate = parseFloat(req.query.rate || '1.5');
const where = {};
if (userId) where.userId = userId;
if (from || to) {
where.date = {};
if (from) where.date.gte = new Date(from + 'T00:00:00Z');
if (to) where.date.lte = new Date(to + 'T23:59:59Z');
}
const entries = await req.prisma.timeEntry.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: [{ userId: 'asc' }, { date: 'asc' }],
});
// Group by user → week
const userWeeks = {};
for (const e of entries) {
const uid = e.userId;
const d = new Date(e.date);
const day = d.getUTCDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setUTCDate(d.getUTCDate() + mondayOffset);
const weekKey = monday.toISOString().split('T')[0];
if (!userWeeks[uid]) userWeeks[uid] = { user: e.user, weeks: {} };
if (!userWeeks[uid].weeks[weekKey]) userWeeks[uid].weeks[weekKey] = { totalHours: 0, entries: 0 };
userWeeks[uid].weeks[weekKey].totalHours += parseFloat(e.hoursWorked) || 0;
userWeeks[uid].weeks[weekKey].entries++;
}
// Compute overtime per user
const results = Object.values(userWeeks).map((uw) => {
let totalRegular = 0;
let totalOvertime = 0;
let totalHours = 0;
const weekBreakdown = [];
for (const [week, data] of Object.entries(uw.weeks)) {
const regular = Math.min(data.totalHours, weeklyThreshold);
const overtime = Math.max(0, data.totalHours - weeklyThreshold);
totalRegular += regular;
totalOvertime += overtime;
totalHours += data.totalHours;
weekBreakdown.push({
week,
totalHours: parseFloat(data.totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
entries: data.entries,
});
}
return {
user: uw.user,
summary: {
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(totalRegular.toFixed(2)),
overtimeHours: parseFloat(totalOvertime.toFixed(2)),
overtimeCost: parseFloat((totalOvertime * overtimeRate).toFixed(2)),
weeksWithOvertime: weekBreakdown.filter((w) => w.overtimeHours > 0).length,
},
weeks: weekBreakdown,
};
});
// Sort by most overtime first
results.sort((a, b) => b.summary.overtimeHours - a.summary.overtimeHours);
res.json({
config: { weeklyThreshold, overtimeRate },
employees: results,
totals: {
totalHours: parseFloat(results.reduce((s, r) => s + r.summary.totalHours, 0).toFixed(2)),
regularHours: parseFloat(results.reduce((s, r) => s + r.summary.regularHours, 0).toFixed(2)),
overtimeHours: parseFloat(results.reduce((s, r) => s + r.summary.overtimeHours, 0).toFixed(2)),
},
});
} catch (err) {
console.error('Overtime report error:', err);
res.status(500).json({ error: 'Failed to generate overtime report' });
}
});
// Employee sees their own overtime
// ─────────────── GET /api/timesheets/overtime ───────────────
// (mounted at /api/timesheets/overtime in timesheets router)
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;
+359
View File
@@ -0,0 +1,359 @@
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' });
}
});
// ─────────────── POST /api/entries/copy-week ───────────────
router.post('/copy-week', async (req, res) => {
try {
const { fromWeek, toWeek } = req.body;
if (!fromWeek || !toWeek) {
return res.status(400).json({ error: 'fromWeek and toWeek are required (YYYY-MM-DD Monday)' });
}
const fromMonday = getWeekMonday(fromWeek);
const toMonday = getWeekMonday(toWeek);
const fromSunday = getWeekSunday(fromMonday);
// Check target week isn't locked
const targetTs = await req.prisma.timesheet.findUnique({
where: { userId_weekStart: { userId: req.user.id, weekStart: new Date(toMonday + 'T00:00:00Z') } },
});
if (targetTs && ['submitted', 'approved'].includes(targetTs.status)) {
return res.status(403).json({ error: 'Target week is locked (submitted or approved)' });
}
// Get source week entries
const sourceEntries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: {
gte: new Date(fromMonday + 'T00:00:00Z'),
lte: new Date(fromSunday + 'T00:00:00Z'),
},
},
include: { homeowner: { select: { id: true, isActive: true } } },
});
if (sourceEntries.length === 0) {
return res.status(404).json({ error: 'No entries found in source week' });
}
// Calculate day offset (Mon=0 ... Sun=6)
const fromStart = new Date(fromMonday + 'T00:00:00Z');
const toStart = new Date(toMonday + 'T00:00:00Z');
const dayOffset = Math.round((toStart - fromStart) / (1000 * 60 * 60 * 24));
// Delete existing entries in target week (that are in draft)
const toSunday = getWeekSunday(toMonday);
await req.prisma.timeEntry.deleteMany({
where: {
userId: req.user.id,
date: {
gte: new Date(toMonday + 'T00:00:00Z'),
lte: new Date(toSunday + 'T00:00:00Z'),
},
},
});
// Copy entries with shifted dates, only for active homeowners
const created = [];
for (const entry of sourceEntries) {
if (!entry.homeowner.isActive) continue;
const oldDate = new Date(entry.date);
const newDate = new Date(oldDate);
newDate.setUTCDate(newDate.getUTCDate() + dayOffset);
const newEntry = await req.prisma.timeEntry.create({
data: {
userId: req.user.id,
date: newDate,
homeownerId: entry.homeownerId,
hoursWorked: entry.hoursWorked,
workDescription: entry.workDescription,
},
include: { homeowner: { select: { name: true } } },
});
created.push({
id: newEntry.id,
date: newEntry.date.toISOString().split('T')[0],
homeownerId: newEntry.homeownerId,
homeownerName: newEntry.homeowner.name,
hoursWorked: newEntry.hoursWorked,
workDescription: newEntry.workDescription,
});
}
res.status(201).json({ copied: created.length, entries: created });
} catch (err) {
console.error('Copy week error:', err);
res.status(500).json({ error: 'Failed to copy week' });
}
});
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;
+341
View File
@@ -0,0 +1,341 @@
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' });
}
});
// ─────────────── GET /api/timesheets/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const weekParam = req.query.week;
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
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') },
},
});
const totalHours = entries.reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0);
const threshold = 40;
const regular = Math.min(totalHours, threshold);
const overtime = Math.max(0, totalHours - threshold);
res.json({
week: monday,
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
threshold,
});
} catch (err) {
console.error('Employee overtime error:', err);
res.status(500).json({ error: 'Failed to get overtime' });
}
});
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,
};
+4
View File
@@ -0,0 +1,4 @@
DB_PASSWORD=coastal_secret
# Set to "true" to enable daily pg_dump backups
ENABLE_BACKUPS=false
BACKUP_DIR=/backups
+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: always
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: always
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: always
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;
}
}
+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 Contracting 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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
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 Contracting
</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>
);
+757
View File
@@ -0,0 +1,757 @@
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, TrendingUp, AlertTriangle } 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);
const [selected, setSelected] = useState(new Set());
const [bulkAction, setBulkAction] = useState(false);
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);
}
}
function toggleSelect(id) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleSelectAll() {
if (selected.size === timesheets.length) {
setSelected(new Set());
} else {
setSelected(new Set(timesheets.map((t) => t.id)));
}
}
async function bulkApprove() {
if (selected.size === 0) return;
setBulkAction(true);
try {
const res = await api.post('/admin/timesheets/bulk-approve', { timesheetIds: [...selected] });
alert(`Approved: ${res.data.approved}${res.data.failed?.length ? `, Failed: ${res.data.failed.length}` : ''}`);
setSelected(new Set());
loadPending();
} catch (err) { alert('Bulk approve failed'); }
finally { setBulkAction(false); }
}
async function bulkReject() {
if (selected.size === 0) return;
const notes = prompt('Rejection reason:');
if (notes === null) return;
setBulkAction(true);
try {
const res = await api.post('/admin/timesheets/bulk-reject', { timesheetIds: [...selected], notes });
alert(`Rejected: ${res.data.rejected}`);
setSelected(new Set());
loadPending();
} catch (err) { alert('Bulk reject failed'); }
finally { setBulkAction(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">
{/* Bulk action bar */}
{timesheets.length > 1 && (
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-800/50 rounded-xl px-4 py-2">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" checked={selected.size === timesheets.length && timesheets.length > 0} onChange={toggleSelectAll} className="rounded" />
{selected.size > 0 ? `${selected.size} selected` : 'Select all'}
</label>
{selected.size > 0 && (
<div className="flex gap-2">
<button onClick={bulkApprove} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600 disabled:opacity-50 flex items-center gap-1">
<Check size={14} /> Approve All
</button>
<button onClick={bulkReject} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-xs font-medium hover:bg-red-600 disabled:opacity-50 flex items-center gap-1">
<X size={14} /> Reject All
</button>
</div>
)}
</div>
)}
{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">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<input type="checkbox" checked={selected.has(ts.id)} onChange={() => toggleSelect(ts.id)} className="rounded" />
<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>
<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 ${u.isActive === false ? 'opacity-50' : ''}`}>
<div className="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>
<div className="flex items-center gap-2">
<select
value={u.role}
onChange={async (e) => {
try {
await api.put(`/admin/users/${u.id}`, { role: e.target.value });
loadUsers();
} catch (err) { alert(err.response?.data?.error || 'Failed'); }
}}
className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
>
<option value="employee">Employee</option>
<option value="admin">Admin</option>
</select>
{u.isActive === false ? (
<button
onClick={async () => {
try { await api.put(`/admin/users/${u.id}`, { isActive: true }); loadUsers(); }
catch (err) { alert('Failed to reactivate'); }
}}
className="text-xs px-2.5 py-1.5 rounded-lg 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 font-medium"
>
Reactivate
</button>
) : (
<button
onClick={async () => {
if (!confirm(`Deactivate ${u.name}? They won't be able to log in.`)) return;
try { await api.delete(`/admin/users/${u.id}`); loadUsers(); }
catch (err) { alert(err.response?.data?.error || 'Failed'); }
}}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
title="Deactivate"
>
<X size={14} />
</button>
)}
</div>
</div>
</div>
))}
</div>
);
}
function ManageHomeowners() {
const [homeowners, setHomeowners] = useState([]);
const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [newForm, setNewForm] = useState({ name: '', address: '' });
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState(null);
const [editForm, setEditForm] = useState({ name: '', address: '' });
const [search, setSearch] = useState('');
useEffect(() => { loadHomeowners(); }, [showInactive]);
async function loadHomeowners() {
try {
const res = await api.get(`/admin/homeowners?includeInactive=${showInactive}`);
setHomeowners(res.data.homeowners || res.data || []);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}
async function addHomeowner(e) {
e.preventDefault();
if (!newForm.name.trim()) return;
setAdding(true);
try {
await api.post('/admin/homeowners', { name: newForm.name.trim(), address: newForm.address.trim() || undefined });
setNewForm({ name: '', address: '' });
setShowAdd(false);
loadHomeowners();
} catch (err) {
alert(err.response?.data?.error || 'Failed to add');
} finally { setAdding(false); }
}
async function saveEdit(id) {
try {
await api.put(`/admin/homeowners/${id}`, { name: editForm.name.trim(), address: editForm.address.trim() || null });
setEditId(null);
loadHomeowners();
} catch (err) {
alert(err.response?.data?.error || 'Failed to update');
}
}
async function toggleActive(h) {
try {
await api.put(`/admin/homeowners/${h.id}`, { isActive: !h.isActive });
loadHomeowners();
} catch (err) { alert('Failed'); }
}
const filtered = homeowners.filter((h) =>
h.name.toLowerCase().includes(search.toLowerCase()) ||
(h.address || '').toLowerCase().includes(search.toLowerCase())
);
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
return (
<div className="space-y-3">
{/* Search + Controls */}
<div className="flex gap-2">
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search homeowners..." 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 onClick={() => setShowAdd(!showAdd)} className="px-3 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 flex items-center gap-1.5">
<Plus size={16} /> Add
</button>
</div>
<label className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} className="rounded" />
Show inactive homeowners
</label>
{/* Add Form */}
{showAdd && (
<form onSubmit={addHomeowner} 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">
<input value={newForm.name} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} required placeholder="Homeowner name (e.g. Smith, 142)" className="w-full 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={newForm.address} onChange={(e) => setNewForm({ ...newForm, address: e.target.value })} placeholder="Address (optional)" className="w-full 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" />
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowAdd(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={adding} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">{adding ? 'Adding...' : 'Add Homeowner'}</button>
</div>
</form>
)}
<div className="text-xs text-gray-400 dark:text-gray-500">{filtered.length} homeowners</div>
{/* Homeowner List */}
<div className="space-y-1">
{filtered.map((h) => (
<div key={h.id} className={`rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 ${h.isActive === false ? 'opacity-50' : ''}`}>
{editId === h.id ? (
/* Edit Mode */
<div className="p-3 space-y-2">
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Name" />
<input value={editForm.address} onChange={(e) => setEditForm({ ...editForm, address: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Address" />
<div className="flex gap-2 justify-end">
<button onClick={() => setEditId(null)} className="px-3 py-1.5 rounded-lg text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
<button onClick={() => saveEdit(h.id)} className="px-3 py-1.5 rounded-lg bg-sky-500 text-white text-xs font-medium hover:bg-sky-600">Save</button>
</div>
</div>
) : (
/* View Mode */
<div className="flex items-center justify-between px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
{h.address && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">#{h.address}</div>}
</div>
<div className="flex items-center gap-1.5 ml-2">
<button
onClick={() => { setEditId(h.id); setEditForm({ name: h.name, address: h.address || '' }); }}
className="p-1.5 rounded-lg text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
title="Edit"
>
<UserPlus size={14} />
</button>
<button
onClick={() => toggleActive(h)}
className={`text-[10px] px-2 py-1 rounded-lg font-medium transition-all ${
h.isActive !== false
? 'text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10'
: 'text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-emerald-50 hover:text-emerald-500 dark:hover:bg-emerald-500/10'
}`}
>
{h.isActive !== false ? 'Active' : 'Inactive'}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
function Reports() {
const [users, setUsers] = useState([]);
const [timesheets, setTimesheets] = useState([]);
const [loading, setLoading] = useState(true);
const [homeowners, setHomeowners] = useState([]);
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '', homeownerId: '' });
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'),
api.get('/admin/homeowners?includeInactive=true'),
]).then(([usersRes, tsRes, hoRes]) => {
setUsers(usersRes.data.users || usersRes.data || []);
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
setHomeowners(hoRes.data.homeowners || hoRes.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);
if (filters.homeownerId) params.set('homeownerId', filters.homeownerId);
if (filters.startDate) params.set('from', filters.startDate);
if (filters.endDate) params.set('to', filters.endDate);
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.homeownerId} onChange={(e) => setFilters({ ...filters, homeownerId: 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 Homeowners</option>
{homeowners.map((h) => <option key={h.id} value={h.id}>{h.name}{h.address ? ` #${h.address}` : ''}</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>
<div></div>
<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>
);
}
function OvertimeReport() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState({ from: '', to: '' });
useEffect(() => { loadOvertime(); }, []);
async function loadOvertime() {
setLoading(true);
try {
const params = new URLSearchParams();
if (dateRange.from) params.set('from', dateRange.from);
if (dateRange.to) params.set('to', dateRange.to);
const res = await api.get(`/admin/overtime?${params}`);
setData(res.data);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
if (!data) return <p className="text-center py-10 text-gray-400">Failed to load</p>;
return (
<div className="space-y-4">
{/* Date filters */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="date" value={dateRange.from} onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })} className="w-full 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" />
</div>
<div className="flex-1">
<label className="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="date" value={dateRange.to} onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })} className="w-full 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" />
</div>
<button onClick={loadOvertime} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600">Filter</button>
</div>
{/* Summary */}
<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">{data.totals.totalHours}</div>
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</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">{data.totals.regularHours}</div>
<div className="text-[10px] text-emerald-500/70 font-medium">REGULAR</div>
</div>
<div className={`rounded-xl p-3 text-center ${data.totals.overtimeHours > 0 ? 'bg-amber-50 dark:bg-amber-500/10' : 'bg-gray-50 dark:bg-gray-800'}`}>
<div className={`text-xl font-bold ${data.totals.overtimeHours > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'}`}>{data.totals.overtimeHours}</div>
<div className="text-[10px] text-amber-500/70 font-medium">OVERTIME</div>
</div>
</div>
{/* Employee list */}
{data.employees.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No data for selected period</p>}
{data.employees.map((emp) => (
<div key={emp.user.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 mb-2">
<div className="font-semibold text-gray-900 dark:text-white">{emp.user.name}</div>
<div className="flex items-center gap-2">
{emp.summary.overtimeHours > 0 && (
<span className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded-lg">
<AlertTriangle size={12} /> {emp.summary.overtimeHours}h OT
</span>
)}
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{emp.summary.totalHours}h</span>
</div>
</div>
{/* Week breakdown */}
<div className="space-y-1">
{emp.weeks.map((w) => (
<div key={w.week} className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Week of {w.week}</span>
<div className="flex items-center gap-3">
<span>{w.regularHours}h reg</span>
{w.overtimeHours > 0 && <span className="text-amber-500 font-medium">+{w.overtimeHours}h OT</span>}
</div>
</div>
))}
</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 === 'overtime'} onClick={() => setTab('overtime')} icon={TrendingUp} label="Overtime" 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 === 'overtime' && <OvertimeReport />}
{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>
);
}
+361
View File
@@ -0,0 +1,361 @@
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, Copy, TrendingUp } 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 [copying, setCopying] = useState(false);
const [overtime, setOvertime] = useState(null);
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);
}
}
// Fetch overtime for current week
useEffect(() => {
if (!weekParam) return;
api.get(`/timesheets/overtime?week=${weekParam}`)
.then((res) => setOvertime(res.data))
.catch(() => setOvertime(null));
}, [weekParam]);
async function handleCopyPrevWeek() {
const prevMonday = new Date(weekDates[0] + 'T00:00:00');
prevMonday.setDate(prevMonday.getDate() - 7);
const fromWeek = toLocalDateStr(prevMonday);
if (!confirm(`Copy entries from week of ${fromWeek} to this week? This will replace any existing entries.`)) return;
setCopying(true);
try {
const res = await api.post('/entries/copy-week', { fromWeek, toWeek: weekParam });
alert(`Copied ${res.data.copied} entries!`);
loadWeek();
} catch (err) {
alert(err.response?.data?.error || 'Failed to copy week');
} finally {
setCopying(false);
}
}
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>
{/* Overtime indicator */}
{overtime && overtime.overtimeHours > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
<TrendingUp size={16} className="text-amber-500" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{overtime.overtimeHours.toFixed(1)}h overtime (over {overtime.threshold}h)
</span>
</div>
)}
{/* Copy previous week button */}
{!isLocked && totalHours === 0 && (
<button
onClick={handleCopyPrevWeek}
disabled={copying}
className="w-full py-2.5 rounded-xl border-2 border-dashed border-sky-200 dark:border-sky-500/30 text-sky-600 dark:text-sky-400 text-sm font-medium flex items-center justify-center gap-2 hover:bg-sky-50 dark:hover:bg-sky-500/5 transition-all active:scale-[0.98]"
>
{copying ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
{copying ? 'Copying...' : 'Copy Previous Week'}
</button>
)}
</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,
},
});
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB