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)
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."*
|
||||
@@ -0,0 +1,22 @@
|
||||
# ─── Database ───────────────────────────────────────────
|
||||
DATABASE_URL=postgresql://coastal:coastal_secret@localhost:5432/coastal_timesheet
|
||||
|
||||
# ─── JWT Secrets ────────────────────────────────────────
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||
JWT_SECRET=change-me-to-a-random-64-byte-hex-string
|
||||
JWT_REFRESH_SECRET=change-me-to-a-different-random-64-byte-hex-string
|
||||
|
||||
# ─── Server ────────────────────────────────────────────
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ─── SMTP (Email) ──────────────────────────────────────
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
# ─── Admin ──────────────────────────────────────────────
|
||||
ADMIN_EMAIL=bizzle@coastalcontracting.com
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "coastal-timesheet-backend",
|
||||
"version": "2.0.0",
|
||||
"description": "Coastal Contracting Timesheet API",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"db:migrate": "npx prisma migrate deploy",
|
||||
"db:push": "npx prisma db push",
|
||||
"db:seed": "node prisma/seed.js",
|
||||
"db:generate": "npx prisma generate",
|
||||
"db:reset": "npx prisma migrate reset --force",
|
||||
"setup": "npx prisma generate && npx prisma db push && node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react": "^18.3.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"}
|
||||
@@ -0,0 +1,106 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
employee
|
||||
admin
|
||||
super_admin
|
||||
}
|
||||
|
||||
enum TimesheetStatus {
|
||||
draft
|
||||
submitted
|
||||
approved
|
||||
rejected
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String
|
||||
role Role @default(employee)
|
||||
passwordHash String @map("password_hash")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
refreshToken String? @map("refresh_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
timeEntries TimeEntry[]
|
||||
timesheets Timesheet[] @relation("UserTimesheets")
|
||||
approvals Timesheet[] @relation("ApprovedTimesheets")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Homeowner {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
timeEntries TimeEntry[]
|
||||
|
||||
@@map("homeowners")
|
||||
}
|
||||
|
||||
model TimeEntry {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
date DateTime @db.Date
|
||||
homeownerId String @map("homeowner_id")
|
||||
hoursWorked Decimal @map("hours_worked") @db.Decimal(4, 2)
|
||||
workDescription String @map("work_description")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
homeowner Homeowner @relation(fields: [homeownerId], references: [id])
|
||||
timesheetLinks TimesheetEntry[]
|
||||
|
||||
@@index([userId, date])
|
||||
@@index([homeownerId])
|
||||
@@map("time_entries")
|
||||
}
|
||||
|
||||
model Timesheet {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
weekStart DateTime @map("week_start") @db.Date
|
||||
weekEnd DateTime @map("week_end") @db.Date
|
||||
status TimesheetStatus @default(draft)
|
||||
submittedAt DateTime? @map("submitted_at")
|
||||
approvedBy String? @map("approved_by")
|
||||
approvedAt DateTime? @map("approved_at")
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation("UserTimesheets", fields: [userId], references: [id], onDelete: Cascade)
|
||||
approver User? @relation("ApprovedTimesheets", fields: [approvedBy], references: [id])
|
||||
entries TimesheetEntry[]
|
||||
|
||||
@@unique([userId, weekStart])
|
||||
@@index([status])
|
||||
@@index([userId, weekStart])
|
||||
@@map("timesheets")
|
||||
}
|
||||
|
||||
model TimesheetEntry {
|
||||
id String @id @default(uuid())
|
||||
timesheetId String @map("timesheet_id")
|
||||
timeEntryId String @map("time_entry_id")
|
||||
|
||||
timesheet Timesheet @relation(fields: [timesheetId], references: [id], onDelete: Cascade)
|
||||
timeEntry TimeEntry @relation(fields: [timeEntryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([timesheetId, timeEntryId])
|
||||
@@map("timesheet_entries")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEFAULT_HOMEOWNERS = [
|
||||
'Anderson, 217',
|
||||
'Bakos',
|
||||
'Beckstead, 111',
|
||||
'Bentley, 310',
|
||||
'Best, 103',
|
||||
'Caraway, 132',
|
||||
'Carmichael, M, 216',
|
||||
'Casa Blanca',
|
||||
'Chapin, 106',
|
||||
'Conner, 309',
|
||||
'Cook, 118',
|
||||
'Coyle, 109',
|
||||
'Davis, 114a',
|
||||
'Dimmitt, 213',
|
||||
'Dockery, 502',
|
||||
'Fassett, 303C',
|
||||
'Gypsy Wind',
|
||||
'Hager, 108',
|
||||
'Hanford, 308',
|
||||
'Hitchcox – Clarry, 218',
|
||||
'Hughes, 215',
|
||||
'Kaufman 129 (Blue View)',
|
||||
'Kuchman, 104',
|
||||
'Lockhart, 301A',
|
||||
'Lokey, 136',
|
||||
'McColgan, 312',
|
||||
'Mercurio, 523',
|
||||
'Moff – Dean Elect',
|
||||
'Rogers, 501',
|
||||
'Rusten, 204A',
|
||||
'Ryan, 301B',
|
||||
'Salas, 144',
|
||||
'Sear 128 (Twin Shores)',
|
||||
'Shimp, 517',
|
||||
'Sipprelle, 202',
|
||||
'Trino, 131',
|
||||
'Useppa Fire',
|
||||
'Vogt',
|
||||
'Weinsz, 141',
|
||||
'Wendorf, 306',
|
||||
'White (Rogan)',
|
||||
'Williams, Bob, 140',
|
||||
'Williams, Dan, 137B',
|
||||
'Williamson-Whetstone, 102',
|
||||
'Wilson, George, 516',
|
||||
'Wright, 137A',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...');
|
||||
|
||||
// Create admin user
|
||||
const passwordHash = await bcrypt.hash('CoastalAdmin2026!', 12);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@coastal.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@coastal.com',
|
||||
name: 'Admin',
|
||||
role: 'super_admin',
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Admin user created: ${admin.email}`);
|
||||
|
||||
// Create homeowners
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
for (const name of DEFAULT_HOMEOWNERS) {
|
||||
try {
|
||||
await prisma.homeowner.upsert({
|
||||
where: { name },
|
||||
update: {},
|
||||
create: { name },
|
||||
});
|
||||
created++;
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Skipped homeowner "${name}": ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
console.log(`✅ Homeowners: ${created} created, ${skipped} skipped`);
|
||||
|
||||
console.log('🌱 Seed complete!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Seed failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const entriesRoutes = require('./routes/entries');
|
||||
const timesheetsRoutes = require('./routes/timesheets');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const homeownersRoutes = require('./routes/homeowners');
|
||||
|
||||
const app = express();
|
||||
const prisma = new PrismaClient();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Trust proxy (behind nginx)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// CORS
|
||||
const allowedOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||
: ['http://localhost:5173', 'http://localhost:3000'];
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, callback) {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Handle malformed JSON errors
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'Invalid JSON body' });
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
// Attach prisma to request
|
||||
app.use((req, _res, next) => {
|
||||
req.prisma = prisma;
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
res.status(503).json({ status: 'error', message: 'Database unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/entries', entriesRoutes);
|
||||
app.use('/api/timesheets', timesheetsRoutes);
|
||||
app.use('/api/homeowners', homeownersRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
if (err.message === 'Not allowed by CORS') {
|
||||
return res.status(403).json({ error: 'CORS policy violation' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
async function shutdown(signal) {
|
||||
console.log(`\n${signal} received. Shutting down gracefully...`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Coastal Timesheet API running on port ${PORT}`);
|
||||
console.log(`📋 Health check: http://localhost:${PORT}/api/health`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,103 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me';
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
/**
|
||||
* Generate an access token (short-lived)
|
||||
*/
|
||||
function generateAccessToken(user) {
|
||||
return jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_EXPIRY }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token (long-lived)
|
||||
*/
|
||||
function generateRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
{ userId: user.id, tokenType: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: REFRESH_TOKEN_EXPIRY }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an access token
|
||||
*/
|
||||
function verifyAccessToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
*/
|
||||
function verifyRefreshToken(token) {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware — requires valid access token
|
||||
*/
|
||||
function authenticate(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const decoded = verifyAccessToken(token);
|
||||
req.user = {
|
||||
id: decoded.userId,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
};
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Access token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid access token' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only middleware — must be called after authenticate
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Super admin middleware — must be called after authenticate
|
||||
*/
|
||||
function requireSuperAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'super_admin') {
|
||||
return res.status(403).json({ error: 'Super admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
requireSuperAdmin,
|
||||
JWT_SECRET,
|
||||
JWT_REFRESH_SECRET,
|
||||
ACCESS_TOKEN_EXPIRY,
|
||||
REFRESH_TOKEN_EXPIRY,
|
||||
};
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,210 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
} = require('../middleware/auth');
|
||||
const {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
refreshSchema,
|
||||
validateBody,
|
||||
} = require('../utils/validation');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limit: 5 attempts per 15 minutes on auth endpoints
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 50,
|
||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/login ───────────────
|
||||
router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.validated;
|
||||
|
||||
const user = await req.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.status(403).json({ error: 'Account is deactivated. Contact your admin.' });
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(user);
|
||||
const refreshToken = generateRefreshToken(user);
|
||||
|
||||
// Store refresh token hash in DB
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/register (admin only) ───────────────
|
||||
router.post(
|
||||
'/register',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
validateBody(registerSchema),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { email, password, name, role } = req.validated;
|
||||
|
||||
// Only super_admin can create admin/super_admin accounts
|
||||
if (
|
||||
(role === 'admin' || role === 'super_admin') &&
|
||||
req.user.role !== 'super_admin'
|
||||
) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'Only super admins can create admin accounts' });
|
||||
}
|
||||
|
||||
const existing = await req.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await req.prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
role,
|
||||
passwordHash,
|
||||
},
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
});
|
||||
|
||||
res.status(201).json({ user });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────── POST /api/auth/refresh ───────────────
|
||||
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = req.validated;
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = verifyRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'User not found or deactivated' });
|
||||
}
|
||||
|
||||
// Verify the refresh token matches the stored one (token rotation)
|
||||
if (user.refreshToken !== refreshToken) {
|
||||
// Possible token theft — invalidate all tokens for this user
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
return res.status(401).json({ error: 'Refresh token reuse detected. Please login again.' });
|
||||
}
|
||||
|
||||
const newAccessToken = generateAccessToken(user);
|
||||
const newRefreshToken = generateRefreshToken(user);
|
||||
|
||||
// Rotate refresh token
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: newRefreshToken },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Refresh error:', err);
|
||||
res.status(500).json({ error: 'Token refresh failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── GET /api/auth/me ───────────────
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch (err) {
|
||||
console.error('Me error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch user profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/logout ───────────────
|
||||
router.post('/logout', authenticate, async (req, res) => {
|
||||
try {
|
||||
await req.prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/homeowners — list active homeowners (for all authenticated users)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const homeowners = await req.prisma.homeowner.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
res.json({ homeowners });
|
||||
} catch (err) {
|
||||
console.error('Get homeowners error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch homeowners' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,122 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
let _transporter = null;
|
||||
|
||||
/**
|
||||
* Get or create the nodemailer transporter (lazy singleton)
|
||||
*/
|
||||
function getTransporter() {
|
||||
if (_transporter) return _transporter;
|
||||
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error(
|
||||
'SMTP not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
_transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass },
|
||||
tls: {
|
||||
// Allow self-signed certs in dev
|
||||
rejectUnauthorized: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
return _transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify SMTP connection is working
|
||||
*/
|
||||
async function verifySmtp() {
|
||||
const transporter = getTransporter();
|
||||
await transporter.verify();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a timesheet PDF via email
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.message - Plain-text body (optional)
|
||||
* @param {Buffer} options.pdfBuffer - PDF file buffer
|
||||
* @param {string} options.pdfFilename - Filename for attachment
|
||||
* @param {string} options.fromName - Sender display name
|
||||
* @returns {Promise<Object>} nodemailer send result
|
||||
*/
|
||||
async function sendTimesheetEmail({
|
||||
to,
|
||||
subject,
|
||||
message,
|
||||
pdfBuffer,
|
||||
pdfFilename,
|
||||
fromName,
|
||||
}) {
|
||||
const transporter = getTransporter();
|
||||
const fromAddress = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #3b82f6; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 20px; letter-spacing: 1px;">
|
||||
COASTAL CONTRACTING OF FL
|
||||
</h1>
|
||||
</div>
|
||||
<div style="padding: 24px; background: #f9fafb; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #1f2937; margin-top: 0;">Timesheet Attached</h2>
|
||||
${message ? `<p style="color: #374151; line-height: 1.6;">${escapeHtml(message)}</p>` : ''}
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
The timesheet PDF is attached to this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
|
||||
Sent from Coastal Timesheet • ${new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const result = await transporter.sendMail({
|
||||
from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
|
||||
to,
|
||||
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
||||
text: message || 'Your timesheet is attached.',
|
||||
html: htmlBody,
|
||||
attachments: [
|
||||
{
|
||||
filename: pdfFilename || 'timesheet.pdf',
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendTimesheetEmail,
|
||||
verifySmtp,
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
renderToBuffer,
|
||||
} = require('@react-pdf/renderer');
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
paddingTop: 25,
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: 30,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
headerSection: {
|
||||
marginBottom: 20,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
letterSpacing: 1.0,
|
||||
},
|
||||
brandLine: {
|
||||
width: 60,
|
||||
height: 3,
|
||||
backgroundColor: '#3b82f6',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 5,
|
||||
},
|
||||
weekInfo: {
|
||||
fontSize: 14,
|
||||
marginBottom: 18,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 4,
|
||||
},
|
||||
employeeInfo: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
backgroundColor: '#f0f9ff',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
daySection: {
|
||||
marginBottom: 8,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
dayHeader: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dayName: {
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
},
|
||||
dayDate: {
|
||||
fontSize: 9,
|
||||
color: '#dbeafe',
|
||||
},
|
||||
dayTotal: {
|
||||
fontSize: 9,
|
||||
color: '#dbeafe',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
entryRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f3f4f6',
|
||||
minHeight: 28,
|
||||
},
|
||||
entryRowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
entryRowAlternate: {
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
entryCell: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 8,
|
||||
fontSize: 9,
|
||||
color: '#374151',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
homeownerCell: {
|
||||
width: '25%',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
},
|
||||
hoursCell: {
|
||||
width: '15%',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
alignItems: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: '#059669',
|
||||
},
|
||||
workDescCell: {
|
||||
width: '60%',
|
||||
},
|
||||
summarySection: {
|
||||
marginTop: 18,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: '#3b82f6',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
},
|
||||
totalHours: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#059669',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
fontSize: 8,
|
||||
bottom: 25,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
color: '#9ca3af',
|
||||
},
|
||||
});
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
/**
|
||||
* Get the 7 days of the week (Monday → Sunday) from a Monday date string
|
||||
*/
|
||||
function getWeekDays(mondayStr) {
|
||||
const days = [];
|
||||
const start = new Date(mondayStr + 'T00:00:00Z');
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start);
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate();
|
||||
const y = date.getUTCFullYear();
|
||||
return `${m}/${d}/${y}`;
|
||||
}
|
||||
|
||||
function formatWeekRange(weekDays) {
|
||||
if (!weekDays.length) return '';
|
||||
const first = weekDays[0];
|
||||
const last = weekDays[weekDays.length - 1];
|
||||
const opts = { month: 'short', day: 'numeric' };
|
||||
const startStr = first.toLocaleDateString('en-US', { ...opts, timeZone: 'UTC' });
|
||||
const endStr = last.toLocaleDateString('en-US', { ...opts, year: 'numeric', timeZone: 'UTC' });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'approved': return { bg: '#dcfce7', text: '#166534' };
|
||||
case 'submitted': return { bg: '#dbeafe', text: '#1e40af' };
|
||||
case 'rejected': return { bg: '#fef2f2', text: '#991b1b' };
|
||||
default: return { bg: '#f3f4f6', text: '#374151' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the React-PDF document element
|
||||
*/
|
||||
function buildTimesheetDocument({ userName, weekStart, entries, status }) {
|
||||
const weekDays = getWeekDays(weekStart);
|
||||
|
||||
// Group entries by date
|
||||
const entriesByDate = {};
|
||||
for (const entry of entries) {
|
||||
const dateKey = typeof entry.date === 'string'
|
||||
? entry.date
|
||||
: entry.date.toISOString().split('T')[0];
|
||||
if (!entriesByDate[dateKey]) entriesByDate[dateKey] = [];
|
||||
entriesByDate[dateKey].push(entry);
|
||||
}
|
||||
|
||||
// Total hours
|
||||
const totalHours = entries.reduce(
|
||||
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const statusColors = getStatusColor(status);
|
||||
|
||||
const el = React.createElement;
|
||||
|
||||
return el(Document, null,
|
||||
el(Page, { size: 'A4', style: styles.page },
|
||||
// Header
|
||||
el(View, { style: styles.headerSection },
|
||||
el(View, { style: styles.brandLine }),
|
||||
el(Text, { style: styles.header }, 'COASTAL CONTRACTING OF FL')
|
||||
),
|
||||
|
||||
// Week range
|
||||
el(View, { style: styles.weekInfo },
|
||||
el(Text, null, `Week of: ${formatWeekRange(weekDays)}`)
|
||||
),
|
||||
|
||||
// Employee name
|
||||
userName
|
||||
? el(View, { style: styles.employeeInfo },
|
||||
el(Text, null, `Employee: ${userName}`)
|
||||
)
|
||||
: null,
|
||||
|
||||
// Status badge
|
||||
status && status !== 'draft'
|
||||
? el(View, {
|
||||
style: {
|
||||
...styles.statusBadge,
|
||||
backgroundColor: statusColors.bg,
|
||||
color: statusColors.text,
|
||||
},
|
||||
},
|
||||
el(Text, {
|
||||
style: { color: statusColors.text },
|
||||
}, `Status: ${status.charAt(0).toUpperCase() + status.slice(1)}`)
|
||||
)
|
||||
: null,
|
||||
|
||||
// Days
|
||||
...weekDays.map((day, dayIndex) => {
|
||||
const dayKey = day.toISOString().split('T')[0];
|
||||
const dayEntries = entriesByDate[dayKey] || [];
|
||||
|
||||
if (dayEntries.length === 0) return null;
|
||||
|
||||
const dayTotal = dayEntries.reduce(
|
||||
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return el(View, { key: dayIndex, style: styles.daySection },
|
||||
// Day header
|
||||
el(View, { style: styles.dayHeader },
|
||||
el(View, null,
|
||||
el(Text, { style: styles.dayName }, DAY_NAMES[day.getUTCDay()]),
|
||||
el(Text, { style: styles.dayDate }, formatDate(day))
|
||||
),
|
||||
el(Text, { style: styles.dayTotal }, `${dayTotal.toFixed(1)} hours`)
|
||||
),
|
||||
|
||||
// Entries
|
||||
...dayEntries.map((entry, entryIndex) => {
|
||||
const isLast = entryIndex === dayEntries.length - 1;
|
||||
const isAlt = entryIndex % 2 === 1;
|
||||
const rowStyle = [
|
||||
styles.entryRow,
|
||||
isLast ? styles.entryRowLast : null,
|
||||
isAlt ? styles.entryRowAlternate : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return el(View, { key: entryIndex, style: rowStyle },
|
||||
el(View, { style: [styles.entryCell, styles.homeownerCell] },
|
||||
el(Text, null, entry.homeownerName || entry.homeowner || '-')
|
||||
),
|
||||
el(View, { style: [styles.entryCell, styles.hoursCell] },
|
||||
el(Text, null, String(entry.hoursWorked || '0'))
|
||||
),
|
||||
el(View, { style: [styles.entryCell, styles.workDescCell] },
|
||||
el(Text, null, entry.workDescription || '-')
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}).filter(Boolean),
|
||||
|
||||
// Summary
|
||||
el(View, { style: styles.summarySection },
|
||||
el(Text, { style: styles.summaryText }, 'Weekly Total'),
|
||||
el(Text, { style: styles.totalHours }, `${totalHours.toFixed(1)} Hours`)
|
||||
),
|
||||
|
||||
// Footer
|
||||
el(Text, { style: styles.footer },
|
||||
`Generated on ${new Date().toLocaleDateString()} • Coastal Contracting of FL`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a timesheet PDF buffer
|
||||
* @param {Object} data - { userName, weekStart, entries, status }
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function generateTimesheetPDF(data) {
|
||||
const doc = buildTimesheetDocument(data);
|
||||
const buffer = await renderToBuffer(doc);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a filename for the PDF
|
||||
*/
|
||||
function buildPdfFilename(userName, weekStart) {
|
||||
const safeName = (userName || 'timesheet')
|
||||
.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.toLowerCase();
|
||||
return `timesheet_${safeName}_${weekStart}.pdf`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateTimesheetPDF,
|
||||
buildPdfFilename,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
const { z } = require('zod');
|
||||
|
||||
// ──────────────────────────── Auth ────────────────────────────
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address').max(255),
|
||||
password: z.string().min(1, 'Password is required').max(128),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address').max(255),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(128),
|
||||
name: z.string().min(1, 'Name is required').max(100).trim(),
|
||||
role: z.enum(['employee', 'admin', 'super_admin']).default('employee'),
|
||||
});
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1, 'Refresh token is required'),
|
||||
});
|
||||
|
||||
// ──────────────────────────── Entries ────────────────────────────
|
||||
|
||||
const createEntrySchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
homeownerId: z.string().uuid('Invalid homeowner ID'),
|
||||
hoursWorked: z
|
||||
.number()
|
||||
.positive('Hours must be positive')
|
||||
.max(24, 'Hours cannot exceed 24'),
|
||||
workDescription: z.string().min(1, 'Description is required').max(1000).trim(),
|
||||
});
|
||||
|
||||
const updateEntrySchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||
homeownerId: z.string().uuid('Invalid homeowner ID').optional(),
|
||||
hoursWorked: z
|
||||
.number()
|
||||
.positive('Hours must be positive')
|
||||
.max(24, 'Hours cannot exceed 24')
|
||||
.optional(),
|
||||
workDescription: z.string().min(1).max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
const weekQuerySchema = z.object({
|
||||
week: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Week must be YYYY-MM-DD (Monday)')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ──────────────────────────── Timesheets ────────────────────────────
|
||||
|
||||
const submitTimesheetSchema = z.object({
|
||||
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
const emailTimesheetSchema = z.object({
|
||||
to: z.string().email('Invalid recipient email').max(255),
|
||||
subject: z.string().max(200).optional(),
|
||||
message: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
// ──────────────────────────── Admin ────────────────────────────
|
||||
|
||||
const approveRejectSchema = z.object({
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
const createUserSchema = registerSchema;
|
||||
|
||||
const createHomeownerSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(200).trim(),
|
||||
address: z.string().max(500).trim().optional().nullable(),
|
||||
});
|
||||
|
||||
const updateHomeownerSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim().optional(),
|
||||
address: z.string().max(500).trim().optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const reportQuerySchema = z.object({
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be YYYY-MM-DD').optional(),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be YYYY-MM-DD').optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
homeownerId: z.string().uuid().optional(),
|
||||
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
||||
});
|
||||
|
||||
// ──────────────────────────── Helpers ────────────────────────────
|
||||
|
||||
/**
|
||||
* Express middleware factory for validating request body with a zod schema
|
||||
*/
|
||||
function validateBody(schema) {
|
||||
return (req, res, next) => {
|
||||
const result = schema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
}));
|
||||
return res.status(400).json({ error: 'Validation failed', details: errors });
|
||||
}
|
||||
req.validated = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware factory for validating query parameters
|
||||
*/
|
||||
function validateQuery(schema) {
|
||||
return (req, res, next) => {
|
||||
const result = schema.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
}));
|
||||
return res.status(400).json({ error: 'Invalid query parameters', details: errors });
|
||||
}
|
||||
req.validatedQuery = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
refreshSchema,
|
||||
createEntrySchema,
|
||||
updateEntrySchema,
|
||||
weekQuerySchema,
|
||||
submitTimesheetSchema,
|
||||
emailTimesheetSchema,
|
||||
approveRejectSchema,
|
||||
createUserSchema,
|
||||
createHomeownerSchema,
|
||||
updateHomeownerSchema,
|
||||
reportQuerySchema,
|
||||
validateBody,
|
||||
validateQuery,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
DB_PASSWORD=coastal_secret
|
||||
# Set to "true" to enable daily pg_dump backups
|
||||
ENABLE_BACKUPS=false
|
||||
BACKUP_DIR=/backups
|
||||
@@ -0,0 +1,55 @@
|
||||
# ───────────────────────────────────────────────────
|
||||
# Coastal Timesheet — Backend Dockerfile
|
||||
# Node 22 Alpine · Prisma · Express
|
||||
# ───────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Install dependencies ──────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY backend/package.json backend/package-lock.json* ./
|
||||
|
||||
# Install production dependencies
|
||||
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
|
||||
|
||||
# ── Stage 2: Build (generate Prisma client) ────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy deps from previous stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY backend/ ./
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# ── Stage 3: Production image ─────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Add non-root user for security
|
||||
RUN addgroup --system --gid 1001 coastal && \
|
||||
adduser --system --uid 1001 coastal
|
||||
|
||||
# Copy application
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/src ./src
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Switch to non-root user
|
||||
USER coastal
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"
|
||||
|
||||
# Start with migration and seed on first run
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"]
|
||||
@@ -0,0 +1,89 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
# ─── PostgreSQL ─────────────────────────────────────────
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: coastal-db
|
||||
restart: 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
|
||||
@@ -0,0 +1,56 @@
|
||||
# ───────────────────────────────────────────────────
|
||||
# Coastal Timesheet — Frontend Dockerfile
|
||||
# Build: Node 22 Alpine → Serve: Nginx Alpine
|
||||
# ───────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Install dependencies ──────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files (frontend lives in the frontend/ directory)
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
|
||||
# ── Stage 2: Build ────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./
|
||||
|
||||
# Set API URL for production build
|
||||
ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
# Build the React app
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Serve with Nginx ─────────────────────
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
|
||||
# Remove default nginx config and static files
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built frontend assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# The nginx config is mounted via docker-compose volume
|
||||
# but we include a fallback in case it's run standalone
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80 443
|
||||
|
||||
# Nginx runs as non-root by default in alpine image
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,81 @@
|
||||
# Coastal Timesheet — Nginx reverse proxy
|
||||
# /api/* → backend:3001
|
||||
# /* → frontend static files
|
||||
|
||||
upstream backend_api {
|
||||
server backend:3001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Client body size (for file uploads if ever needed)
|
||||
client_max_body_size 10m;
|
||||
|
||||
# ─── API proxy ───────────────────────────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts for PDF generation / email sending
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# ─── Frontend static files ───────────────────────────
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — serve index.html for client-side routes
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets aggressively
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Health check endpoint for load balancer ─────────
|
||||
location = /nginx-health {
|
||||
access_log off;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
/* ─── Token helpers ─── */
|
||||
|
||||
function getAccessToken() {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
return localStorage.getItem('refreshToken');
|
||||
}
|
||||
|
||||
function setTokens(accessToken, refreshToken) {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/* ─── Request interceptor: attach access token ─── */
|
||||
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
/* ─── Response interceptor: auto-refresh on 401 ─── */
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue = [];
|
||||
|
||||
function processQueue(error, token) {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
/* If 401 with TOKEN_EXPIRED and we haven't retried yet */
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
error.response?.data?.code === 'TOKEN_EXPIRED' &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
/* Queue this request until the refresh completes */
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
/* Call refresh endpoint directly (bypass interceptor) */
|
||||
const { data } = await axios.post('/api/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
processQueue(null, data.accessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
clearTokens();
|
||||
/* Redirect to login */
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* If 401 on non-refresh, clear and redirect */
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest.url?.includes('/auth/refresh')
|
||||
) {
|
||||
clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import EntryForm from './EntryForm';
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
// Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift)
|
||||
const [y, m, dy] = date.split('-').map(Number);
|
||||
const d = new Date(y, m - 1, dy);
|
||||
const isToday = new Date().toLocaleDateString('en-CA') === date;
|
||||
const dayName = DAY_NAMES[d.getDay()];
|
||||
const shortDay = SHORT_DAYS[d.getDay()];
|
||||
const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
const totalHours = (entries || []).reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
|
||||
const entryCount = (entries || []).filter((e) => e.homeownerId || e.hoursWorked || e.workDescription).length;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border transition-all ${
|
||||
isToday
|
||||
? 'border-sky-200 dark:border-sky-500/30 bg-sky-50/30 dark:bg-sky-500/5 shadow-sm shadow-sky-100 dark:shadow-none'
|
||||
: 'border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900/50'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-sm font-bold ${
|
||||
isToday
|
||||
? 'bg-sky-500 text-white shadow-md shadow-sky-500/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{shortDay}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{dayName}</span>
|
||||
{isToday && <span className="text-[10px] font-bold text-sky-500 bg-sky-100 dark:bg-sky-500/20 px-1.5 py-0.5 rounded-md">TODAY</span>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{dateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalHours > 0 && (
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{totalHours.toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
{entryCount > 0 && (
|
||||
<span className="text-xs bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400 px-2 py-0.5 rounded-full font-medium">
|
||||
{entryCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={18} className={`text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Entries */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{(entries || []).map((entry) => (
|
||||
<EntryForm
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
homeowners={homeowners}
|
||||
onChange={onEntryChange}
|
||||
onDelete={onDeleteEntry}
|
||||
canDelete={(entries || []).length > 1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => onAddEntry(date)}
|
||||
className="w-full py-2.5 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 text-gray-400 hover:text-sky-500 hover:border-sky-300 dark:hover:border-sky-500/40 text-sm font-medium flex items-center justify-center gap-1.5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import HomeownerSelect from './HomeownerSelect';
|
||||
|
||||
export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) {
|
||||
function handleChange(field, value) {
|
||||
onChange(entry.id, { ...entry, [field]: value });
|
||||
}
|
||||
|
||||
const hasInput = entry.homeownerId || entry.hoursWorked || entry.workDescription;
|
||||
const isComplete = entry.homeownerId && entry.hoursWorked && entry.workDescription;
|
||||
const isPartial = hasInput && !isComplete;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 space-y-3 transition-all ${
|
||||
isPartial
|
||||
? 'border-amber-300 dark:border-amber-500/40 bg-amber-50/50 dark:bg-amber-500/5'
|
||||
: 'border-gray-200 dark:border-gray-700/60 bg-white dark:bg-gray-800/50'
|
||||
} ${disabled ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
{/* Homeowner */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Homeowner {isPartial && !entry.homeownerId && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<HomeownerSelect
|
||||
homeowners={homeowners}
|
||||
value={entry.homeownerId || ''}
|
||||
onChange={(v) => handleChange('homeownerId', v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours + Delete */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Hours {isPartial && !entry.hoursWorked && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
max="24"
|
||||
value={entry.hoursWorked || ''}
|
||||
onChange={(e) => handleChange('hoursWorked', e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="0.0"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
{canDelete && !disabled && (
|
||||
<button
|
||||
onClick={() => onDelete(entry.id)}
|
||||
className="p-2.5 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
||||
title="Remove entry"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Work Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Work Description {isPartial && !entry.workDescription && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={entry.workDescription || ''}
|
||||
onChange={(e) => handleChange('workDescription', e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Describe work performed..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm resize-none focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Plus, Search } from 'lucide-react';
|
||||
|
||||
export default function HomeownerSelect({ homeowners, value, onChange, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) inputRef.current.focus();
|
||||
}, [open]);
|
||||
|
||||
const filtered = homeowners.filter((h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedLabel = homeowners.find((h) => h.id === value)?.name || '';
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl border text-left text-sm transition-all ${
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white hover:border-sky-400 focus:ring-2 focus:ring-sky-500/40'
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? '' : 'text-gray-400'}>{selectedLabel || 'Select homeowner...'}</span>
|
||||
<ChevronDown size={16} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl shadow-gray-200/50 dark:shadow-black/40 max-h-64 overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border-none text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-48 p-1">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
{filtered.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||
h.id === value
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && search && !adding && (
|
||||
<div className="px-3 py-4 text-center">
|
||||
<p className="text-sm text-gray-400 mb-2">No match found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { Calendar, Clock, Shield, LogOut, Menu, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Clock, label: 'Timesheet' },
|
||||
{ to: '/history', icon: Calendar, label: 'History' },
|
||||
...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200/60 dark:border-gray-800/60">
|
||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-semibold bg-gradient-to-r from-sky-500 to-teal-400 bg-clip-text text-transparent">
|
||||
Coastal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Check, Loader2, AlertCircle, CloudOff } from 'lucide-react';
|
||||
|
||||
const STATUS_MAP = {
|
||||
idle: {
|
||||
icon: null,
|
||||
text: '',
|
||||
color: '',
|
||||
},
|
||||
saving: {
|
||||
icon: Loader2,
|
||||
text: 'Saving…',
|
||||
color: 'text-ocean-500',
|
||||
animate: 'animate-spin',
|
||||
},
|
||||
saved: {
|
||||
icon: Check,
|
||||
text: 'Saved',
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
text: 'Save failed',
|
||||
color: 'text-red-500',
|
||||
},
|
||||
offline: {
|
||||
icon: CloudOff,
|
||||
text: 'Offline',
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SaveIndicator({ status = 'idle' }) {
|
||||
const config = STATUS_MAP[status] || STATUS_MAP.idle;
|
||||
|
||||
if (!config.icon) return null;
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${config.color} transition-opacity duration-300`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} className={config.animate || ''} />
|
||||
<span>{config.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
FileEdit,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
draft: {
|
||||
label: 'Draft',
|
||||
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||
text: 'text-gray-600 dark:text-gray-400',
|
||||
icon: FileEdit,
|
||||
},
|
||||
submitted: {
|
||||
label: 'Submitted',
|
||||
bg: 'bg-ocean-50 dark:bg-ocean-950',
|
||||
text: 'text-ocean-600 dark:text-ocean-400',
|
||||
icon: Send,
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
bg: 'bg-red-50 dark:bg-red-950',
|
||||
text: 'text-red-600 dark:text-red-400',
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = 'md' }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.draft;
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'px-2 py-0.5 text-[10px] gap-1'
|
||||
: 'px-3 py-1 text-xs gap-1.5';
|
||||
|
||||
const iconSize = size === 'sm' ? 10 : 12;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`badge ${config.bg} ${config.text} ${sizeClasses}`}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { isDark, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="btn-ghost relative w-10 h-10 p-0 rounded-full"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||
isDark ? 'opacity-0 rotate-90 scale-50' : 'opacity-100 rotate-0 scale-100'
|
||||
}`}
|
||||
>
|
||||
<Sun size={18} className="text-amber-500" />
|
||||
</span>
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||
isDark ? 'opacity-100 rotate-0 scale-100' : 'opacity-0 -rotate-90 scale-50'
|
||||
}`}
|
||||
>
|
||||
<Moon size={18} className="text-ocean-300" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
|
||||
|
||||
function getWeekRange(date) {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const start = new Date(d);
|
||||
start.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); // Monday
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6); // Sunday
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function isCurrentWeek(date) {
|
||||
const now = new Date();
|
||||
const { start, end } = getWeekRange(date);
|
||||
return now >= start && now <= end;
|
||||
}
|
||||
|
||||
export default function WeekNavigator({ selectedDate, onDateChange, onSwipeHandlers }) {
|
||||
const { start, end } = getWeekRange(selectedDate);
|
||||
|
||||
function prevWeek() {
|
||||
const d = new Date(selectedDate);
|
||||
d.setDate(d.getDate() - 7);
|
||||
onDateChange(d);
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
const d = new Date(selectedDate);
|
||||
d.setDate(d.getDate() + 7);
|
||||
onDateChange(d);
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
onDateChange(new Date());
|
||||
}
|
||||
|
||||
const current = isCurrentWeek(selectedDate);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between" {...(onSwipeHandlers || {})}>
|
||||
<button
|
||||
onClick={prevWeek}
|
||||
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatDate(start)} — {formatDate(end)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{start.getFullYear()}
|
||||
{current && <span className="ml-2 text-sky-500 font-medium">This Week</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!current && (
|
||||
<button
|
||||
onClick={goToday}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 text-xs font-medium hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={nextWeek}
|
||||
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getWeekRange };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('user');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
/* On mount, verify the stored token is still valid */
|
||||
useEffect(() => {
|
||||
async function verify() {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get('/auth/me');
|
||||
setUser(data.user);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
} catch {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
verify();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
/* ignore — we clear locally regardless */
|
||||
}
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAdmin,
|
||||
isAuthenticated: !!user,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Auto-save hook with debouncing and visual status.
|
||||
*
|
||||
* Returns:
|
||||
* saveStatus — 'idle' | 'saving' | 'saved' | 'error'
|
||||
* triggerSave — call with an async fn that does the actual save
|
||||
* resetStatus — manually reset to idle
|
||||
*/
|
||||
export function useAutoSave(debounceMs = 500) {
|
||||
const [saveStatus, setSaveStatus] = useState('idle');
|
||||
const timerRef = useRef(null);
|
||||
const savedTimerRef = useRef(null);
|
||||
|
||||
/* Clean up on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSave = useCallback(
|
||||
(saveFn) => {
|
||||
/* Clear any pending debounce */
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await saveFn();
|
||||
setSaveStatus('saved');
|
||||
/* Revert to idle after 3 seconds */
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Auto-save error:', err);
|
||||
setSaveStatus('error');
|
||||
/* Revert to idle after 5 seconds */
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
}, 5000);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
const resetStatus = useCallback(() => setSaveStatus('idle'), []);
|
||||
|
||||
return { saveStatus, triggerSave, resetStatus };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Simple swipe detection hook for mobile week navigation.
|
||||
*
|
||||
* Usage:
|
||||
* const { onTouchStart, onTouchEnd } = useSwipe({ onSwipeLeft, onSwipeRight });
|
||||
* <div onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
|
||||
*/
|
||||
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 50 }) {
|
||||
const touchStartX = useRef(0);
|
||||
const touchStartY = useRef(0);
|
||||
|
||||
const onTouchStart = useCallback((e) => {
|
||||
touchStartX.current = e.changedTouches[0].clientX;
|
||||
touchStartY.current = e.changedTouches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
||||
|
||||
/* Only trigger if horizontal swipe is dominant */
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
|
||||
if (deltaX < 0 && onSwipeLeft) {
|
||||
onSwipeLeft();
|
||||
} else if (deltaX > 0 && onSwipeRight) {
|
||||
onSwipeRight();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSwipeLeft, onSwipeRight, threshold]
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchEnd };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export function useTheme() {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) return stored === 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
/* Listen for system theme changes when no preference is stored */
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function handleChange(e) {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (!stored) {
|
||||
setIsDark(e.matches);
|
||||
}
|
||||
}
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setIsDark((prev) => !prev), []);
|
||||
|
||||
return { isDark, toggle };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Apple-like Design System ── */
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
font-feature-settings: 'kern' 1, 'liga' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Safe area padding for notched phones */
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Remove default focus outlines, add our own */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
@apply ring-2 ring-ocean-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Smooth scrolling containers */
|
||||
.scroll-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism card */
|
||||
.glass-card {
|
||||
@apply bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl;
|
||||
@apply border border-gray-200/50 dark:border-gray-700/50;
|
||||
@apply rounded-2xl shadow-soft;
|
||||
}
|
||||
|
||||
/* Elevated card */
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-900;
|
||||
@apply border border-gray-200 dark:border-gray-800;
|
||||
@apply rounded-2xl shadow-soft;
|
||||
@apply transition-shadow duration-200;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply shadow-soft-lg;
|
||||
}
|
||||
|
||||
/* Apple-style input */
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3;
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
@apply border border-transparent;
|
||||
@apply rounded-xl;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply placeholder-gray-400 dark:placeholder-gray-500;
|
||||
@apply transition-all duration-200;
|
||||
@apply text-base;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
@apply bg-white dark:bg-gray-700;
|
||||
@apply border-ocean-500;
|
||||
@apply ring-2 ring-ocean-500/20;
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-ocean-500 hover:bg-ocean-600 active:bg-ocean-700;
|
||||
@apply text-white font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply shadow-sm hover:shadow-md active:shadow-sm;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@apply bg-ocean-300 dark:bg-ocean-800 cursor-not-allowed shadow-none;
|
||||
@apply active:scale-100;
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700;
|
||||
@apply text-gray-700 dark:text-gray-300 font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Success button (for submit) */
|
||||
.btn-success {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-4;
|
||||
@apply bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700;
|
||||
@apply text-white font-bold text-lg;
|
||||
@apply rounded-2xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply shadow-lg shadow-emerald-500/25 hover:shadow-xl hover:shadow-emerald-500/30;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.btn-success:disabled {
|
||||
@apply bg-emerald-300 dark:bg-emerald-800 cursor-not-allowed shadow-none;
|
||||
@apply active:scale-100;
|
||||
}
|
||||
|
||||
/* Danger button */
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-red-500 hover:bg-red-600 active:bg-red-700;
|
||||
@apply text-white font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-4 py-2;
|
||||
@apply text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Badge base */
|
||||
.badge {
|
||||
@apply inline-flex items-center;
|
||||
@apply px-3 py-1;
|
||||
@apply text-xs font-semibold uppercase tracking-wider;
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.section-title {
|
||||
@apply text-xs font-semibold uppercase tracking-wider;
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
@apply mb-2 px-1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly sizing */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transitions for route changes ── */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ── Custom scrollbar for desktop ── */
|
||||
@media (hover: hover) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-700;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Skeleton loading ── */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.gray.200') 25%,
|
||||
theme('colors.gray.100') 50%,
|
||||
theme('colors.gray.200') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.gray.800') 25%,
|
||||
theme('colors.gray.700') 50%,
|
||||
theme('colors.gray.800') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Timesheet from './pages/Timesheet';
|
||||
import History from './pages/History';
|
||||
import Admin from './pages/Admin';
|
||||
import './index.css';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><div className="animate-spin w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full" /></div>;
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return isAdmin ? children : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route index element={<Timesheet />} />
|
||||
<Route path="history" element={<History />} />
|
||||
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Download, Loader2, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
export default function History() {
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api.get('/timesheets/history');
|
||||
const data = res.data;
|
||||
setTimesheets(Array.isArray(data) ? data : (data.timesheets || []));
|
||||
} catch (err) {
|
||||
console.error('Failed to load history:', err);
|
||||
setTimesheets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function downloadPdf(id, weekStart) {
|
||||
try {
|
||||
const res = await api.get(`/timesheets/${id}/pdf`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `timesheet-${weekStart}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert('Failed to download PDF');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-20"><Loader2 size={32} className="animate-spin text-sky-500" /></div>;
|
||||
}
|
||||
|
||||
if (timesheets.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Calendar size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">No timesheets yet</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Submit your first timesheet to see it here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Timesheet History</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
{timesheets.map((ts) => {
|
||||
const [sy, sm, sd] = ts.weekStart.split('-').map(Number);
|
||||
const [ey, em, ed] = ts.weekEnd.split('-').map(Number);
|
||||
const start = new Date(sy, sm - 1, sd);
|
||||
const end = new Date(ey, em - 1, ed);
|
||||
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<Clock size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{fmt(start)} — {fmt(end)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={ts.status} />
|
||||
{ts.totalHours && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{parseFloat(ts.totalHours).toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(ts.status === 'submitted' || ts.status === 'approved') && (
|
||||
<button
|
||||
onClick={() => downloadPdf(ts.id, ts.weekStart)}
|
||||
className="p-2.5 rounded-xl text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-sky-500 to-teal-400 shadow-lg shadow-sky-500/25 mb-4">
|
||||
<span className="text-2xl font-bold text-white">C</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Coastal Timesheet</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to track your hours</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl shadow-gray-200/50 dark:shadow-black/30 border border-gray-200/60 dark:border-gray-800/60 p-6 space-y-5">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 text-sm rounded-xl px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
placeholder="you@coastal.com"
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 text-white font-semibold text-base shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <><Loader2 size={18} className="animate-spin" /> Signing in...</> : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 dark:text-gray-600 mt-6">
|
||||
Coastal Contracting of FL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ocean: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
coastal: {
|
||||
50: '#f0fdfa',
|
||||
100: '#ccfbf1',
|
||||
200: '#99f6e4',
|
||||
300: '#5eead4',
|
||||
400: '#2dd4bf',
|
||||
500: '#14b8a6',
|
||||
600: '#0d9488',
|
||||
700: '#0f766e',
|
||||
800: '#115e59',
|
||||
900: '#134e4a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'SF Pro Display',
|
||||
'SF Pro Text',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
'soft-lg': '0 10px 40px -10px rgba(0, 0, 0, 0.1), 0 2px 10px -2px rgba(0, 0, 0, 0.04)',
|
||||
'inner-soft': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { opacity: '0', transform: 'translateY(-10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
pulseSoft: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.5' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/forms')],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 89 KiB |