Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cfcce0b64 | ||
|
|
9952d873c7 | ||
|
|
8c49fe1ec8 | ||
|
|
e37b838718 | ||
|
|
7433ec8d9e | ||
|
|
2f60d7d980 | ||
|
|
2f381ff5f9 | ||
|
|
efafbea297 | ||
|
|
a7c138add1 | ||
|
|
68832bd958 | ||
|
|
a90b77b9b5 | ||
|
|
b5e7c8796f | ||
|
|
435ebf6bd5 | ||
|
|
d8bb989f8c | ||
|
|
c913946c08 |
@@ -0,0 +1,22 @@
|
|||||||
|
# ─── Database ───────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://coastal:coastal_secret@localhost:5432/coastal_timesheet
|
||||||
|
|
||||||
|
# ─── JWT Secrets ────────────────────────────────────────
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
JWT_SECRET=change-me-to-a-random-64-byte-hex-string
|
||||||
|
JWT_REFRESH_SECRET=change-me-to-a-different-random-64-byte-hex-string
|
||||||
|
|
||||||
|
# ─── Server ────────────────────────────────────────────
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ─── SMTP (Email) ──────────────────────────────────────
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
# ─── Admin ──────────────────────────────────────────────
|
||||||
|
ADMIN_EMAIL=bizzle@coastalcontracting.com
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
# Secrets
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
docker/.env
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
dist/
|
||||||
# Runtime
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
.prisma/
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
# 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."*
|
|
||||||
@@ -1,12 +1,304 @@
|
|||||||
# Timesheet
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/01-login-mobile.png" alt="Coastal Timesheet" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A web app shipped by Bizzle.
|
<h1 align="center">Coastal Timesheet v2</h1>
|
||||||
|
|
||||||
## Screenshots
|
<p align="center">
|
||||||
|
<strong>A modern, mobile-first time tracking application for Coastal Contracting of FL</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
### Login
|
<p align="center">
|
||||||

|
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=white" alt="React 18" />
|
||||||
|
<img src="https://img.shields.io/badge/Express-4-000000?logo=express&logoColor=white" alt="Express" />
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
|
||||||
|
<img src="https://img.shields.io/badge/Prisma-ORM-2D3748?logo=prisma&logoColor=white" alt="Prisma" />
|
||||||
|
<img src="https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||||
|
<img src="https://img.shields.io/badge/TailwindCSS-3-06B6D4?logo=tailwindcss&logoColor=white" alt="Tailwind" />
|
||||||
|
</p>
|
||||||
|
|
||||||
### Home
|
---
|
||||||

|
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **📱 Mobile-first design** — Built for field workers, optimized for phones
|
||||||
|
- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting
|
||||||
|
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
|
||||||
|
- **🏠 Multiple homeowners per day** — Track work at different job sites
|
||||||
|
- **📋 Copy Previous Week** — One tap to duplicate last week's entries as a template
|
||||||
|
- **✅ Submit → Approve workflow** — Employees submit, admins approve or reject
|
||||||
|
- **⚡ Bulk Approve/Reject** — Select all + approve 40 timesheets in one click
|
||||||
|
- **⏱️ Overtime Tracking** — Real-time OT warnings for employees, admin reports with per-employee weekly breakdown
|
||||||
|
- **📄 PDF generation** — Professional server-side PDF export
|
||||||
|
- **📧 Email integration** — Send timesheets via email with SMTP
|
||||||
|
- **👥 Admin panel** — Manage employees, homeowners, review timesheets
|
||||||
|
- **📊 Reporting** — Filter by employee, homeowner, date range, status
|
||||||
|
- **🏡 Homeowner management** — Add, edit, search, activate/deactivate with address fields
|
||||||
|
- **🌙 Dark mode** — System-aware with manual toggle
|
||||||
|
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Screenshots
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>Timesheet Entry</strong></td>
|
||||||
|
<td align="center"><strong>Copy Previous Week</strong></td>
|
||||||
|
<td align="center"><strong>Dark Mode</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/02-timesheet-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/08-timesheet-copy-week.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/07-dark-mode-mobile.png" width="250" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>Bulk Approve</strong></td>
|
||||||
|
<td align="center"><strong>Overtime Tracking</strong></td>
|
||||||
|
<td align="center"><strong>Reports & Filters</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/09-admin-bulk-approve.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/10-admin-overtime.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/12-admin-reports-filters.png" width="250" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>Homeowner Management</strong></td>
|
||||||
|
<td align="center"><strong>History</strong></td>
|
||||||
|
<td align="center"><strong>Entry Form</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/11-admin-homeowners.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/04-history-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
<img src="docs/screenshots/02-timesheet-desktop.png" width="700" />
|
||||||
|
<img src="docs/screenshots/06-admin-reports-desktop.png" width="700" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||||
|
- That's it. Everything else runs in containers.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://git.bizzle.lol/bizzle/coastal_timesheet.git
|
||||||
|
cd coastal_timesheet
|
||||||
|
git checkout v2
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your secrets (see Configuration below)
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
cd docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost` (port 80).
|
||||||
|
|
||||||
|
### Default Admin Account
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|----------|-------------------------|
|
||||||
|
| Email | `admin@coastal.com` |
|
||||||
|
| Password | `CoastalAdmin2026!` |
|
||||||
|
|
||||||
|
> ⚠️ **Change the admin password after first login.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (auto-configured in Docker)
|
||||||
|
DB_PASSWORD=your-secure-db-password
|
||||||
|
|
||||||
|
# JWT Secrets (CHANGE THESE!)
|
||||||
|
JWT_SECRET=your-jwt-secret-min-32-chars
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars
|
||||||
|
|
||||||
|
# CORS (add your domain)
|
||||||
|
CORS_ORIGINS=https://timesheets.yourdomain.com
|
||||||
|
|
||||||
|
# Email (optional — for sending timesheets)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=timesheets@yourdomain.com
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Setup (Gmail)
|
||||||
|
|
||||||
|
1. Enable 2FA on your Google account
|
||||||
|
2. Go to [App Passwords](https://myaccount.google.com/apppasswords)
|
||||||
|
3. Generate a new app password for "Mail"
|
||||||
|
4. Use that as `SMTP_PASS`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Nginx │
|
||||||
|
│ (reverse proxy) │
|
||||||
|
│ /api/* → backend:3001 │
|
||||||
|
│ /* → static frontend │
|
||||||
|
├──────────────────┬──────────────────────────┤
|
||||||
|
│ Frontend │ Backend │
|
||||||
|
│ React + Vite │ Express + Prisma │
|
||||||
|
│ Tailwind CSS │ JWT Auth │
|
||||||
|
│ Lucide Icons │ @react-pdf/renderer │
|
||||||
|
│ │ Nodemailer │
|
||||||
|
│ ├──────────────────────────┤
|
||||||
|
│ │ PostgreSQL 16 │
|
||||||
|
│ │ (persistent volume) │
|
||||||
|
└──────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-----------|-----------------------------------------------|
|
||||||
|
| Frontend | React 18, Vite 6, Tailwind CSS 3, Lucide |
|
||||||
|
| Backend | Express 4, Prisma ORM, bcryptjs, jsonwebtoken |
|
||||||
|
| Database | PostgreSQL 16 (Alpine) |
|
||||||
|
| PDF | @react-pdf/renderer (server-side) |
|
||||||
|
| Email | Nodemailer + SMTP |
|
||||||
|
| Proxy | Nginx 1.27 (Alpine) |
|
||||||
|
| Container | Docker Compose v3.9 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── frontend/ # React SPA
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # Axios client with token refresh
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── contexts/ # Auth context (JWT)
|
||||||
|
│ │ ├── hooks/ # Custom hooks (theme, swipe, auto-save)
|
||||||
|
│ │ └── pages/ # Route pages
|
||||||
|
│ └── vite.config.js
|
||||||
|
├── backend/ # Express API
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ ├── schema.prisma # Database schema
|
||||||
|
│ │ └── seed.js # Seed admin + homeowners
|
||||||
|
│ └── src/
|
||||||
|
│ ├── middleware/ # Auth middleware
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ └── utils/ # PDF, email, validation
|
||||||
|
├── docker/ # Deployment
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── backend/Dockerfile
|
||||||
|
│ ├── frontend/Dockerfile
|
||||||
|
│ └── nginx/default.conf
|
||||||
|
└── docs/screenshots/ # App screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
- **bcrypt** password hashing (12 rounds)
|
||||||
|
- **JWT** access tokens (15min) + refresh tokens (7 days)
|
||||||
|
- **Helmet** security headers
|
||||||
|
- **Rate limiting** on auth endpoints (50 req / 15 min)
|
||||||
|
- **Zod** input validation on all endpoints
|
||||||
|
- **Prisma ORM** — parameterized queries (no SQL injection)
|
||||||
|
- **Non-root Docker** containers
|
||||||
|
- **CORS** origin whitelist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------------------|----------------------|
|
||||||
|
| POST | `/api/auth/login` | Login, get tokens |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
|
| GET | `/api/auth/me` | Current user info |
|
||||||
|
|
||||||
|
### Entries
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|---------------------|------------------------|
|
||||||
|
| GET | `/api/entries` | List entries (by week) |
|
||||||
|
| POST | `/api/entries` | Create entry |
|
||||||
|
| PUT | `/api/entries/:id` | Update entry |
|
||||||
|
| DELETE | `/api/entries/:id` | Delete entry |
|
||||||
|
|
||||||
|
### Timesheets
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|------------------------------|----------------------|
|
||||||
|
| GET | `/api/timesheets` | Get current week |
|
||||||
|
| GET | `/api/timesheets/history` | All user timesheets |
|
||||||
|
| POST | `/api/timesheets/submit` | Submit for approval |
|
||||||
|
| GET | `/api/timesheets/:id/pdf` | Download PDF |
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|-------------------------------------|-------------------------|
|
||||||
|
| GET | `/api/admin/timesheets` | All timesheets (filter) |
|
||||||
|
| GET | `/api/admin/timesheets/:id` | Timesheet detail |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/approve` | Approve timesheet |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/reject` | Reject timesheet |
|
||||||
|
| PUT | `/api/admin/timesheets/:id/reopen` | Reopen for editing |
|
||||||
|
| GET | `/api/admin/users` | List employees |
|
||||||
|
| POST | `/api/admin/users` | Create employee |
|
||||||
|
| GET | `/api/admin/homeowners` | List homeowners |
|
||||||
|
| POST | `/api/admin/homeowners` | Add homeowner |
|
||||||
|
| GET | `/api/admin/reports` | Reporting with filters |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Upgrading from v1
|
||||||
|
|
||||||
|
v2 is a complete rewrite. Key differences:
|
||||||
|
|
||||||
|
| Feature | v1 | v2 |
|
||||||
|
|-----------------|-----------------------------|----------------------------------|
|
||||||
|
| Storage | Browser localStorage | PostgreSQL database |
|
||||||
|
| Auth | None | JWT with roles |
|
||||||
|
| Multi-user | No | Yes — unlimited employees |
|
||||||
|
| Approval flow | No | Submit → Approve/Reject |
|
||||||
|
| PDF | Client-side (jsPDF) | Server-side (@react-pdf) |
|
||||||
|
| Email | mailto: link | SMTP with PDF attachment |
|
||||||
|
| Deploy | Static HTML | Docker Compose (one command) |
|
||||||
|
| Admin panel | No | Full admin with reporting |
|
||||||
|
| Dark mode | No | System-aware + manual toggle |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
Private — Coastal Contracting of FL. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Built with ☀️ in Florida
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^6.10.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"provider":"postgresql"}
|
|
||||||
@@ -9,7 +9,6 @@ datasource db {
|
|||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
employee
|
employee
|
||||||
office
|
|
||||||
admin
|
admin
|
||||||
super_admin
|
super_admin
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,147 +4,52 @@ const bcrypt = require('bcryptjs');
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const DEFAULT_HOMEOWNERS = [
|
const DEFAULT_HOMEOWNERS = [
|
||||||
'Freeman, 101',
|
'Anderson, 217',
|
||||||
'Williamson (Clam Shell Cottage), 102',
|
'Bakos',
|
||||||
'Best, Steve (Hog Heaven), 103',
|
'Beckstead, 111',
|
||||||
'Kuchman, Jeff & Cindy (Easter Cottage), 104',
|
'Bentley, 310',
|
||||||
'Parsons, David & Donna (Ibis Cottage), 105A',
|
'Best, 103',
|
||||||
'Carroll / Young (Egret Cottage), 105B',
|
'Caraway, 132',
|
||||||
'Chapin, Kay & Charlie (Morning Glory), 106',
|
'Carmichael, M, 216',
|
||||||
'Young (Sanibel Cottage), 107',
|
'Casa Blanca',
|
||||||
'Hager A (Banyan Cottage), 108',
|
'Chapin, 106',
|
||||||
'Coyle (Cayo Costa), 109',
|
'Conner, 309',
|
||||||
'Bound, Simon (Mondongo), 110',
|
'Cook, 118',
|
||||||
'Beckstead (A Gasparilla, B Sunset I), 111AB',
|
'Coyle, 109',
|
||||||
'Symonds (Captiva Cottage), 112',
|
'Davis, 114a',
|
||||||
'Kerr (Shell Cottage), 113',
|
'Dimmitt, 213',
|
||||||
'Davis, Beth (White Sands Cottage), 114A',
|
'Dockery, 502',
|
||||||
'UIDC (Turtle Grass Cottage), 114B',
|
'Fassett, 303C',
|
||||||
'Fletemeyer, John, 115',
|
'Gypsy Wind',
|
||||||
'Kimberg (Cottage 16 North), 116A',
|
'Hager, 108',
|
||||||
'Corey (Bamboo Cottage), 116B',
|
'Hanford, 308',
|
||||||
'Wilson, Clyde (High Tide), 117',
|
'Hitchcox – Clarry, 218',
|
||||||
'Cook, Randy & Beata (Cottage 18), 118',
|
'Hughes, 215',
|
||||||
'Miller (Folly Cottage), 119',
|
'Kaufman 129 (Blue View)',
|
||||||
'Miller (Honeymoon Cottage), 120',
|
'Kuchman, 104',
|
||||||
'Miller (Conch Out Cottage), 121',
|
'Lockhart, 301A',
|
||||||
'Miller - gazebo, 122',
|
'Lokey, 136',
|
||||||
'Simpson (North Point Cottage), 123A',
|
'McColgan, 312',
|
||||||
'Amsler, Virginia (Ginny) (Whelk Cottage), 123B',
|
'Mercurio, 523',
|
||||||
'Prosser (Sea Mystic), 124',
|
'Moff – Dean Elect',
|
||||||
'Strickland, Bonnie, 125',
|
'Rogers, 501',
|
||||||
'Beisswenger/ Kruzi, 126',
|
'Rusten, 204A',
|
||||||
'Albert, Michael (Cottage 27), 127',
|
'Ryan, 301B',
|
||||||
'Sear (Twin Shore), 128',
|
'Salas, 144',
|
||||||
'Kaufman, Steve (Blue View), 129',
|
'Sear 128 (Twin Shores)',
|
||||||
'Fetter, Tim & Megan, 130A',
|
'Shimp, 517',
|
||||||
'Ullrich, David & Anja, 130B',
|
'Sipprelle, 202',
|
||||||
'Trino, Vic & Suzy, 131',
|
'Trino, 131',
|
||||||
'Caraway, Jim (Dolphin Cottage), 132',
|
'Useppa Fire',
|
||||||
'Escobar, Rick (Little Mermaid), 133',
|
'Vogt',
|
||||||
'Vance, Paul (Serne Mermaid), 134',
|
'Weinsz, 141',
|
||||||
'Lokey (lot), 135',
|
'Wendorf, 306',
|
||||||
'Lokey, Phil & Shannon, 136',
|
'White (Rogan)',
|
||||||
'Wright, Clark (Fish Hawk), 137A',
|
|
||||||
'Williams, Dan, 137B',
|
|
||||||
'Preckwinkle, George, 138A',
|
|
||||||
'Preckwinkle, George (Calusa South), 138B',
|
|
||||||
'Collier - lot, 139',
|
|
||||||
'Williams, Bob, 140',
|
'Williams, Bob, 140',
|
||||||
'Weinsz, Steve, 141',
|
'Williams, Dan, 137B',
|
||||||
'Lawler, Michael (Sea level), 142A',
|
'Williamson-Whetstone, 102',
|
||||||
'Lawler (Sea Level Cottage), 142B',
|
'Wilson, George, 516',
|
||||||
'Ketay, 143',
|
'Wright, 137A',
|
||||||
'Salas, Joe (West Wind), 144',
|
|
||||||
'Buchanan (Eagles Nest), 145',
|
|
||||||
'Buchanan (Eagles Nest), 146',
|
|
||||||
'Nutting - lot, 147',
|
|
||||||
'Firehouse, 200',
|
|
||||||
'Smith, Sawyer (Alibi Cottage), 201',
|
|
||||||
'Sipprelle, Dwight (The Aerie), 202',
|
|
||||||
'Sipprelle -lot, 203',
|
|
||||||
'Rusten, Brendt (Take Five), 204A',
|
|
||||||
'Useppa One LLC (Rusten), 204B',
|
|
||||||
'Useppa One LLC (Rusten)- gazebo, 205',
|
|
||||||
'Mezyninski, Steve (Hill Tide), 206',
|
|
||||||
'Sullivan, 207',
|
|
||||||
'Sullivan, 208',
|
|
||||||
'Nutting (A Little Piece of Heaven), 209',
|
|
||||||
'UIDC - Donald (Live Oak Cottage), 210',
|
|
||||||
'Newbold, Carol (Palm Cottage), 211',
|
|
||||||
'Dimmitt, Peter (Lookout Cottage), 212',
|
|
||||||
'Dimmitt, Peter (Lookout Cottage), 213',
|
|
||||||
'Greenwell (Hide-A-Way Cottage), 214',
|
|
||||||
'Hughes, Dave (Cottage 15), 215',
|
|
||||||
'Carmichael, Michael (8 Palms), 216',
|
|
||||||
'Anderson, Donna (Island Time), 217',
|
|
||||||
'Hitchcox, Doug, 218',
|
|
||||||
'Lockhart, Kim (Coquina Cottage), 301A',
|
|
||||||
'Ryan, Doug & Cindy, 301B',
|
|
||||||
'Duke - lot, 302',
|
|
||||||
'Stuart, Deb, 303A',
|
|
||||||
'Judge, James (Osprey Point), 303B',
|
|
||||||
'Fassett, Ladd (Cottage 3AN), 303C',
|
|
||||||
'Ink (Cottage 3AS), 303D',
|
|
||||||
'McGinn (Gaspar Island), 304',
|
|
||||||
'McGinn (Gaspar Island), 305',
|
|
||||||
'Wendorf, Bruce & Hillary (It\'s a Wendorful Life Cottage), 306',
|
|
||||||
'Bennett, 307',
|
|
||||||
'Hanford, Ken & Kim, 308',
|
|
||||||
'Conner, Drew & Paige (Campfire Lighter), 309',
|
|
||||||
'Bentley, Joel & Sue, 310',
|
|
||||||
'Covington/Bernard, 311A',
|
|
||||||
'O\'Connell (Often Inn), 311B',
|
|
||||||
'McColgan, Brian (La Costa Sunset), 312',
|
|
||||||
'Preckwinkle, George, 313',
|
|
||||||
'Perrone (Lagoona Vista), 314',
|
|
||||||
'Thompson, Jim, 315',
|
|
||||||
'Nutting - lot, 316',
|
|
||||||
'Nutting - lot, 317',
|
|
||||||
'Kent / Williamson (Kaos Kattage), 318',
|
|
||||||
'Kahane -lot, 319',
|
|
||||||
'Kahane (Belvedere Cottage?), 320',
|
|
||||||
'Nutting - lot, 321',
|
|
||||||
'Molosky, Andrew, 322A',
|
|
||||||
'Folkerth, Betty (Living Water Cottage), 322B',
|
|
||||||
'Rowars, Chuck (Mangoes), 323',
|
|
||||||
'Eldemir, Alex, 324',
|
|
||||||
'Paradise Partnership, 325',
|
|
||||||
'Ricciardelli - lot, 326',
|
|
||||||
'Cardwell - lot, 327',
|
|
||||||
'Spencer, Doug & Holly, 328',
|
|
||||||
'Walker, Jeff & Alicia (HighSeas), 329',
|
|
||||||
'Loeks - lot, 401',
|
|
||||||
'Loeks - lot, 402',
|
|
||||||
'Loeks - lot, 403',
|
|
||||||
'Wilson, George - lot, 404',
|
|
||||||
'Nutting - lot, 405',
|
|
||||||
'Nutting - lot, 406',
|
|
||||||
'Soriero - lot, 407',
|
|
||||||
'Alderman, Gary, 501',
|
|
||||||
'Dockery, Bob & Susan, 502',
|
|
||||||
'Hansen (Starfish Cottage), 503',
|
|
||||||
'Berger / Bevis (Castaway), 504',
|
|
||||||
'Tinney, John, 505',
|
|
||||||
'Matter, John, 506',
|
|
||||||
'Stuart (Fancy Free), 507',
|
|
||||||
'Fernandez, 508',
|
|
||||||
'Wiesen, Matt & Katy, 509',
|
|
||||||
'Romine, Dave, 510',
|
|
||||||
'Handin (Grandview Cottage), 511',
|
|
||||||
'Colgan, Tony (Cottage 12), 512',
|
|
||||||
'Cupello (White Rose Cottage), 513',
|
|
||||||
'Meyer (Burgee Cottage), 514',
|
|
||||||
'Jarvis, Jim & Patty (Laguna Cottage), 515',
|
|
||||||
'Wilson, George (Cottage 16), 516',
|
|
||||||
'Shimp, Kevin, 517',
|
|
||||||
'Dreher, Brucke (Island Escape), 518',
|
|
||||||
'Beckman (Mangrove Cottage), 519',
|
|
||||||
'Gatewood (Water Wings), 520',
|
|
||||||
'Duke, Steve & Janie (Dutchess Cottage), 521',
|
|
||||||
'Useppa Island LLC, 522',
|
|
||||||
'Mercurio, Michael & Beth (Osprey Nest), 523',
|
|
||||||
'Compton, Janie (Dvorak), 524',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
@@ -32,7 +31,6 @@ app.use(
|
|||||||
if (!origin || allowedOrigins.includes(origin)) {
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
} else {
|
} else {
|
||||||
console.error(`CORS rejected origin: ${origin} (allowed: ${allowedOrigins.join(', ')})`);
|
|
||||||
callback(new Error('Not allowed by CORS'));
|
callback(new Error('Not allowed by CORS'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -40,17 +38,6 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global rate limiting (300 requests per 15 minutes per IP)
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 300,
|
|
||||||
message: { error: 'Too many requests, please try again later.' },
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req) => req.ip,
|
|
||||||
});
|
|
||||||
app.use('/api/', apiLimiter);
|
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
/**
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me';
|
||||||
* Hash a refresh token for secure DB storage
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me';
|
||||||
*/
|
|
||||||
function hashRefreshToken(token) {
|
|
||||||
return crypto.createHash('sha256').update(token).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
|
||||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
|
|
||||||
|
|
||||||
if (!JWT_SECRET || !JWT_REFRESH_SECRET) {
|
|
||||||
console.error('FATAL: JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCESS_TOKEN_EXPIRY = '15m';
|
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
@@ -85,7 +72,7 @@ function authenticate(req, res, next) {
|
|||||||
* Admin-only middleware — must be called after authenticate
|
* Admin-only middleware — must be called after authenticate
|
||||||
*/
|
*/
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
if (!req.user || !['office', 'admin', 'super_admin'].includes(req.user.role)) {
|
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) {
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@@ -106,7 +93,6 @@ module.exports = {
|
|||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
verifyRefreshToken,
|
verifyRefreshToken,
|
||||||
hashRefreshToken,
|
|
||||||
authenticate,
|
authenticate,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireSuperAdmin,
|
requireSuperAdmin,
|
||||||
|
|||||||
@@ -3,18 +3,13 @@ const bcrypt = require('bcryptjs');
|
|||||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||||
const {
|
const {
|
||||||
approveRejectSchema,
|
approveRejectSchema,
|
||||||
bulkOperationSchema,
|
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
updateUserSchema,
|
|
||||||
createHomeownerSchema,
|
createHomeownerSchema,
|
||||||
updateHomeownerSchema,
|
updateHomeownerSchema,
|
||||||
reportQuerySchema,
|
reportQuerySchema,
|
||||||
adminTimesheetQuerySchema,
|
resetPasswordSchema,
|
||||||
adminOvertimeQuerySchema,
|
|
||||||
adminReportPdfQuerySchema,
|
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
validateIdParam,
|
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
|
|
||||||
@@ -28,9 +23,9 @@ router.use(authenticate, requireAdmin);
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
|
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
|
||||||
router.get('/timesheets', validateQuery(adminTimesheetQuerySchema), async (req, res) => {
|
router.get('/timesheets', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, userId, page = '1', limit = '50' } = req.validatedQuery;
|
const { status, userId, page = '1', limit = '50' } = req.query;
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
@@ -94,7 +89,7 @@ router.get('/timesheets', validateQuery(adminTimesheetQuerySchema), async (req,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/timesheets/:id ───────────────
|
// ─────────────── GET /api/admin/timesheets/:id ───────────────
|
||||||
router.get('/timesheets/:id', validateIdParam, async (req, res) => {
|
router.get('/timesheets/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -148,9 +143,15 @@ router.get('/timesheets/:id', validateIdParam, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
|
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
|
||||||
router.post('/timesheets/bulk-approve', validateBody(bulkOperationSchema), async (req, res) => {
|
router.post('/timesheets/bulk-approve', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { timesheetIds, notes } = req.validated;
|
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: [] };
|
const results = { approved: 0, failed: [] };
|
||||||
for (const id of timesheetIds) {
|
for (const id of timesheetIds) {
|
||||||
@@ -173,9 +174,12 @@ router.post('/timesheets/bulk-approve', validateBody(bulkOperationSchema), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
|
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
|
||||||
router.post('/timesheets/bulk-reject', validateBody(bulkOperationSchema), async (req, res) => {
|
router.post('/timesheets/bulk-reject', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { timesheetIds, notes } = req.validated;
|
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: [] };
|
const results = { rejected: 0, failed: [] };
|
||||||
for (const id of timesheetIds) {
|
for (const id of timesheetIds) {
|
||||||
@@ -200,7 +204,6 @@ router.post('/timesheets/bulk-reject', validateBody(bulkOperationSchema), async
|
|||||||
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/timesheets/:id/approve',
|
'/timesheets/:id/approve',
|
||||||
validateIdParam,
|
|
||||||
validateBody(approveRejectSchema),
|
validateBody(approveRejectSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -248,7 +251,6 @@ router.put(
|
|||||||
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/timesheets/:id/reject',
|
'/timesheets/:id/reject',
|
||||||
validateIdParam,
|
|
||||||
validateBody(approveRejectSchema),
|
validateBody(approveRejectSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -293,7 +295,7 @@ router.put(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
|
||||||
router.put('/timesheets/:id/reopen', validateIdParam, async (req, res) => {
|
router.put('/timesheets/:id/reopen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||||
@@ -394,10 +396,10 @@ router.post('/users', validateBody(createUserSchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── PUT /api/admin/users/:id ───────────────
|
// ─────────────── PUT /api/admin/users/:id ───────────────
|
||||||
router.put('/users/:id', validateIdParam, validateBody(updateUserSchema), async (req, res) => {
|
router.put('/users/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, role, isActive, password } = req.validated;
|
const { name, role, isActive, password } = req.body;
|
||||||
|
|
||||||
const user = await req.prisma.user.findUnique({ where: { id } });
|
const user = await req.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -438,7 +440,7 @@ router.put('/users/:id', validateIdParam, validateBody(updateUserSchema), async
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
|
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
|
||||||
router.delete('/users/:id', validateIdParam, async (req, res) => {
|
router.delete('/users/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (id === req.user.id) {
|
if (id === req.user.id) {
|
||||||
@@ -523,7 +525,6 @@ router.post(
|
|||||||
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
|
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/homeowners/:id',
|
'/homeowners/:id',
|
||||||
validateIdParam,
|
|
||||||
validateBody(updateHomeownerSchema),
|
validateBody(updateHomeownerSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -698,9 +699,13 @@ router.get('/reports', validateQuery(reportQuerySchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/reports/pdf ───────────────
|
// ─────────────── GET /api/admin/reports/pdf ───────────────
|
||||||
router.get('/reports/pdf', validateQuery(adminReportPdfQuerySchema), async (req, res) => {
|
router.get('/reports/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId, weekStart } = req.validatedQuery;
|
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({
|
const user = await req.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -760,11 +765,11 @@ router.get('/reports/pdf', validateQuery(adminReportPdfQuerySchema), async (req,
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/overtime ───────────────
|
// ─────────────── GET /api/admin/overtime ───────────────
|
||||||
router.get('/overtime', validateQuery(adminOvertimeQuerySchema), async (req, res) => {
|
router.get('/overtime', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { from, to, userId } = req.validatedQuery;
|
const { from, to, userId } = req.query;
|
||||||
const weeklyThreshold = parseFloat(req.validatedQuery.threshold || '40');
|
const weeklyThreshold = parseFloat(req.query.threshold || '40');
|
||||||
const overtimeRate = parseFloat(req.validatedQuery.rate || '1.5');
|
const overtimeRate = parseFloat(req.query.rate || '1.5');
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (userId) where.userId = userId;
|
if (userId) where.userId = userId;
|
||||||
@@ -856,29 +861,6 @@ router.get('/overtime', validateQuery(adminOvertimeQuerySchema), async (req, res
|
|||||||
// ─────────────── GET /api/timesheets/overtime ───────────────
|
// ─────────────── GET /api/timesheets/overtime ───────────────
|
||||||
// (mounted at /api/timesheets/overtime in timesheets router)
|
// (mounted at /api/timesheets/overtime in timesheets router)
|
||||||
|
|
||||||
// ─────────────── PUT /api/admin/users/:id/reset-password ───────────────
|
|
||||||
router.put('/users/:id/reset-password', validateIdParam, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { newPassword } = req.body;
|
|
||||||
if (!newPassword || newPassword.length < 8) {
|
|
||||||
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const hash = await bcrypt.hash(newPassword, 10);
|
|
||||||
const user = await req.prisma.user.update({
|
|
||||||
where: { id: req.params.id },
|
|
||||||
data: { passwordHash: hash, refreshToken: null },
|
|
||||||
select: { id: true, email: true, name: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: `Password reset for ${user.name}`, user });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Reset password error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to reset password' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// ADMIN PASSWORD RESET
|
// ADMIN PASSWORD RESET
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const {
|
|||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
verifyRefreshToken,
|
verifyRefreshToken,
|
||||||
hashRefreshToken,
|
|
||||||
authenticate,
|
authenticate,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
} = require('../middleware/auth');
|
} = require('../middleware/auth');
|
||||||
@@ -19,10 +18,10 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Rate limit: 10 attempts per 15 minutes on auth endpoints (login/register)
|
// Rate limit: 5 attempts per 15 minutes on auth endpoints
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 50,
|
||||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
@@ -51,10 +50,10 @@ router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) =
|
|||||||
const accessToken = generateAccessToken(user);
|
const accessToken = generateAccessToken(user);
|
||||||
const refreshToken = generateRefreshToken(user);
|
const refreshToken = generateRefreshToken(user);
|
||||||
|
|
||||||
// Store hashed refresh token in DB
|
// Store refresh token hash in DB
|
||||||
await req.prisma.user.update({
|
await req.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { refreshToken: hashRefreshToken(refreshToken) },
|
data: { refreshToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -120,18 +119,8 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate limit refresh: 30 per 15 minutes (higher than login since auto-refresh is normal)
|
|
||||||
const refreshLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 30,
|
|
||||||
message: { error: 'Too many refresh attempts. Please login again.' },
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req) => req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────── POST /api/auth/refresh ───────────────
|
// ─────────────── POST /api/auth/refresh ───────────────
|
||||||
router.post('/refresh', refreshLimiter, validateBody(refreshSchema), async (req, res) => {
|
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { refreshToken } = req.validated;
|
const { refreshToken } = req.validated;
|
||||||
|
|
||||||
@@ -150,8 +139,8 @@ router.post('/refresh', refreshLimiter, validateBody(refreshSchema), async (req,
|
|||||||
return res.status(401).json({ error: 'User not found or deactivated' });
|
return res.status(401).json({ error: 'User not found or deactivated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the refresh token hash matches the stored one (token rotation)
|
// Verify the refresh token matches the stored one (token rotation)
|
||||||
if (user.refreshToken !== hashRefreshToken(refreshToken)) {
|
if (user.refreshToken !== refreshToken) {
|
||||||
// Possible token theft — invalidate all tokens for this user
|
// Possible token theft — invalidate all tokens for this user
|
||||||
await req.prisma.user.update({
|
await req.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
@@ -163,10 +152,10 @@ router.post('/refresh', refreshLimiter, validateBody(refreshSchema), async (req,
|
|||||||
const newAccessToken = generateAccessToken(user);
|
const newAccessToken = generateAccessToken(user);
|
||||||
const newRefreshToken = generateRefreshToken(user);
|
const newRefreshToken = generateRefreshToken(user);
|
||||||
|
|
||||||
// Rotate refresh token (store hash)
|
// Rotate refresh token
|
||||||
await req.prisma.user.update({
|
await req.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { refreshToken: hashRefreshToken(newRefreshToken) },
|
data: { refreshToken: newRefreshToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -219,37 +208,6 @@ router.post('/logout', authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── PUT /api/auth/change-password ───────────────
|
|
||||||
router.put('/change-password', authenticate, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
|
||||||
if (!currentPassword || !newPassword) {
|
|
||||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
|
||||||
}
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await req.prisma.user.findUnique({ where: { id: req.user.id } });
|
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
||||||
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
|
||||||
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(newPassword, 10);
|
|
||||||
await req.prisma.user.update({
|
|
||||||
where: { id: req.user.id },
|
|
||||||
data: { passwordHash: hash, refreshToken: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Password changed successfully. Please log in again.' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Change password error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to change password' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────── PUT /api/auth/profile ───────────────
|
// ─────────────── PUT /api/auth/profile ───────────────
|
||||||
router.put('/profile', authenticate, validateBody(updateProfileSchema), async (req, res) => {
|
router.put('/profile', authenticate, validateBody(updateProfileSchema), async (req, res) => {
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ const {
|
|||||||
createEntrySchema,
|
createEntrySchema,
|
||||||
updateEntrySchema,
|
updateEntrySchema,
|
||||||
weekQuerySchema,
|
weekQuerySchema,
|
||||||
copyWeekSchema,
|
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
validateIdParam,
|
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -120,18 +118,6 @@ router.post('/', validateBody(createEntrySchema), async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check per-day hour cap (max 24h total across all entries)
|
|
||||||
const existingHours = await req.prisma.timeEntry.aggregate({
|
|
||||||
where: { userId: req.user.id, date: new Date(date + 'T00:00:00Z') },
|
|
||||||
_sum: { hoursWorked: true },
|
|
||||||
});
|
|
||||||
const totalForDay = parseFloat(existingHours._sum.hoursWorked || 0) + hoursWorked;
|
|
||||||
if (totalForDay > 24) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `Total hours for this day would be ${totalForDay.toFixed(1)}. Maximum is 24.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = await req.prisma.timeEntry.create({
|
const entry = await req.prisma.timeEntry.create({
|
||||||
data: {
|
data: {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -164,7 +150,7 @@ router.post('/', validateBody(createEntrySchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── PUT /api/entries/:id ───────────────
|
// ─────────────── PUT /api/entries/:id ───────────────
|
||||||
router.put('/:id', validateIdParam, validateBody(updateEntrySchema), async (req, res) => {
|
router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -243,7 +229,7 @@ router.put('/:id', validateIdParam, validateBody(updateEntrySchema), async (req,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── DELETE /api/entries/:id ───────────────
|
// ─────────────── DELETE /api/entries/:id ───────────────
|
||||||
router.delete('/:id', validateIdParam, async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -283,9 +269,12 @@ router.delete('/:id', validateIdParam, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── POST /api/entries/copy-week ───────────────
|
// ─────────────── POST /api/entries/copy-week ───────────────
|
||||||
router.post('/copy-week', validateBody(copyWeekSchema), async (req, res) => {
|
router.post('/copy-week', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { fromWeek, toWeek } = req.validated;
|
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 fromMonday = getWeekMonday(fromWeek);
|
||||||
const toMonday = getWeekMonday(toWeek);
|
const toMonday = getWeekMonday(toWeek);
|
||||||
|
|||||||
@@ -21,3 +21,30 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
// POST /api/homeowners — any authenticated user can add a homeowner
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name || typeof name !== "string" || name.trim().length < 2) {
|
||||||
|
return res.status(400).json({ error: "Name must be at least 2 characters" });
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await req.prisma.homeowner.findFirst({
|
||||||
|
where: { name: { equals: trimmed, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
// Return existing rather than error — convenient for the UI
|
||||||
|
return res.json({ homeowner: existing, existing: true });
|
||||||
|
}
|
||||||
|
const homeowner = await req.prisma.homeowner.create({
|
||||||
|
data: { name: trimmed },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
res.status(201).json({ homeowner });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Create homeowner error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to create homeowner" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const {
|
|||||||
weekQuerySchema,
|
weekQuerySchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
validateIdParam,
|
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
const { sendTimesheetEmail } = require('../utils/email');
|
const { sendTimesheetEmail } = require('../utils/email');
|
||||||
@@ -15,6 +14,24 @@ const router = express.Router();
|
|||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ─────────────── Email Rate Limiter ───────────────
|
||||||
|
const emailRateLimit = new Map(); // timesheetId -> { count, lastSentAt }
|
||||||
|
const MAX_EMAILS = 3;
|
||||||
|
const COOLDOWN_MS = 30 * 60 * 1000; // 30 min
|
||||||
|
|
||||||
|
function checkEmailLimit(id) {
|
||||||
|
const now = Date.now();
|
||||||
|
const r = emailRateLimit.get(id) || { count: 0, lastSentAt: 0 };
|
||||||
|
const cooldownMs = Math.max(0, r.lastSentAt + COOLDOWN_MS - now);
|
||||||
|
return { allowed: cooldownMs === 0 && r.count < MAX_EMAILS, cooldownMs, count: r.count };
|
||||||
|
}
|
||||||
|
function recordEmailSent(id) {
|
||||||
|
const r = emailRateLimit.get(id) || { count: 0, lastSentAt: 0 };
|
||||||
|
emailRateLimit.set(id, { count: r.count + 1, lastSentAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute Monday of the week for a given date
|
* Compute Monday of the week for a given date
|
||||||
*/
|
*/
|
||||||
@@ -115,21 +132,12 @@ router.get('/history', async (req, res) => {
|
|||||||
orderBy: { weekStart: 'desc' },
|
orderBy: { weekStart: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
approver: { select: { name: true } },
|
approver: { select: { name: true } },
|
||||||
entries: {
|
|
||||||
include: {
|
|
||||||
timeEntry: { select: { hoursWorked: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { entries: true } },
|
_count: { select: { entries: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
timesheets: timesheets.map((ts) => {
|
timesheets: timesheets.map((ts) => ({
|
||||||
const totalHours = ts.entries.reduce(
|
|
||||||
(sum, link) => sum + parseFloat(link.timeEntry.hoursWorked || 0), 0
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: ts.id,
|
id: ts.id,
|
||||||
weekStart: ts.weekStart.toISOString().split('T')[0],
|
weekStart: ts.weekStart.toISOString().split('T')[0],
|
||||||
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
||||||
@@ -138,10 +146,9 @@ router.get('/history', async (req, res) => {
|
|||||||
approvedAt: ts.approvedAt,
|
approvedAt: ts.approvedAt,
|
||||||
approvedBy: ts.approver?.name || null,
|
approvedBy: ts.approver?.name || null,
|
||||||
notes: ts.notes,
|
notes: ts.notes,
|
||||||
totalHours: parseFloat(totalHours.toFixed(2)),
|
totalHours: null, // Could aggregate if needed
|
||||||
entryCount: ts._count.entries,
|
entryCount: ts._count.entries,
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get history error:', err);
|
console.error('Get history error:', err);
|
||||||
@@ -171,22 +178,6 @@ router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) =>
|
|||||||
return res.status(400).json({ error: 'Cannot submit an empty timesheet' });
|
return res.status(400).json({ error: 'Cannot submit an empty timesheet' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already submitted or approved — prevent resubmission
|
|
||||||
const existingTs = await req.prisma.timesheet.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_weekStart: {
|
|
||||||
userId: req.user.id,
|
|
||||||
weekStart: new Date(monday + 'T00:00:00Z'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingTs && ['submitted', 'approved'].includes(existingTs.status)) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: `Timesheet is already ${existingTs.status}. Contact admin to reopen.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert the timesheet
|
// Upsert the timesheet
|
||||||
const timesheet = await req.prisma.timesheet.upsert({
|
const timesheet = await req.prisma.timesheet.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -240,7 +231,7 @@ router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
|
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
|
||||||
router.get('/:id/pdf', validateIdParam, async (req, res) => {
|
router.get('/:id/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -281,49 +272,62 @@ router.get('/:id/pdf', validateIdParam, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────── GET /api/timesheets/:id/email-status ───────────────
|
||||||
|
router.get('/:id/email-status', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const timesheet = await req.prisma.timesheet.findUnique({ where: { id }, select: { userId: true } });
|
||||||
|
if (!timesheet) return res.status(404).json({ error: 'Not found' });
|
||||||
|
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 limit = checkEmailLimit(id);
|
||||||
|
res.json({ count: limit.count, max: MAX_EMAILS, cooldownMs: limit.cooldownMs, allowed: limit.allowed });
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────── POST /api/timesheets/:id/email ───────────────
|
// ─────────────── POST /api/timesheets/:id/email ───────────────
|
||||||
router.post('/:id/email', validateIdParam, validateBody(emailTimesheetSchema), async (req, res) => {
|
router.post('/:id/email', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { to, subject, message } = req.validated;
|
|
||||||
|
|
||||||
const timesheet = await req.prisma.timesheet.findUnique({
|
const timesheet = await req.prisma.timesheet.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!timesheet) {
|
if (!timesheet) return res.status(404).json({ error: 'Timesheet not found' });
|
||||||
return res.status(404).json({ error: 'Timesheet not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only owner or admin
|
|
||||||
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
|
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
|
||||||
if (timesheet.userId !== req.user.id && !isAdmin) {
|
if (timesheet.userId !== req.user.id && !isAdmin) {
|
||||||
return res.status(403).json({ error: 'Access denied' });
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
const limit = checkEmailLimit(id);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
const minutesLeft = Math.ceil(limit.cooldownMs / 60000);
|
||||||
|
if (limit.count >= MAX_EMAILS) {
|
||||||
|
return res.status(429).json({ error: `Maximum ${MAX_EMAILS} emails per timesheet reached.`, count: limit.count, max: MAX_EMAILS });
|
||||||
|
}
|
||||||
|
return res.status(429).json({ error: `Please wait ${minutesLeft} minute${minutesLeft !== 1 ? 's' : ''} before sending again.`, cooldownMs: limit.cooldownMs, count: limit.count });
|
||||||
|
}
|
||||||
|
|
||||||
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
|
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
|
||||||
const data = await loadTimesheetData(req.prisma, timesheet.userId, weekStart);
|
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 pdfBuffer = await generateTimesheetPDF({
|
|
||||||
userName: data.user.name,
|
|
||||||
weekStart,
|
|
||||||
entries: data.entries,
|
|
||||||
status: timesheet.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfFilename = buildPdfFilename(data.user.name, weekStart);
|
const pdfFilename = buildPdfFilename(data.user.name, weekStart);
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'Office@CoastalContractingFL.com';
|
||||||
|
|
||||||
await sendTimesheetEmail({
|
await sendTimesheetEmail({
|
||||||
to,
|
to: adminEmail,
|
||||||
subject: subject || `Timesheet – ${data.user.name} – Week of ${weekStart}`,
|
subject: `Timesheet – ${data.user.name} – Week of ${weekStart}`,
|
||||||
message,
|
message: `${data.user.name} has submitted their timesheet for the week of ${weekStart}.`,
|
||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
pdfFilename,
|
pdfFilename,
|
||||||
fromName: data.user.name,
|
fromName: data.user.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: `Timesheet emailed to ${to}` });
|
recordEmailSent(id);
|
||||||
|
const newCount = limit.count + 1;
|
||||||
|
res.json({ message: `Timesheet emailed to ${adminEmail}`, count: newCount, max: MAX_EMAILS, remaining: MAX_EMAILS - newCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Email timesheet error:', err);
|
console.error('Email timesheet error:', err);
|
||||||
if (err.message && err.message.includes('SMTP')) {
|
if (err.message && err.message.includes('SMTP')) {
|
||||||
|
|||||||
@@ -86,13 +86,8 @@ async function sendTimesheetEmail({
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Sanitize fromName to prevent email header injection
|
|
||||||
const safeName = fromName
|
|
||||||
? fromName.replace(/[\r\n"\\]/g, '').slice(0, 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const result = await transporter.sendMail({
|
const result = await transporter.sendMail({
|
||||||
from: safeName ? `"${safeName}" <${fromAddress}>` : fromAddress,
|
from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
|
||||||
to,
|
to,
|
||||||
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
||||||
text: message || 'Your timesheet is attached.',
|
text: message || 'Your timesheet is attached.',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const registerSchema = z.object({
|
|||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, 'Password must be at least 8 characters')
|
||||||
.max(128),
|
.max(128),
|
||||||
name: z.string().min(1, 'Name is required').max(100).trim(),
|
name: z.string().min(1, 'Name is required').max(100).trim(),
|
||||||
role: z.enum(['employee', 'office', 'admin', 'super_admin']).default('employee'),
|
role: z.enum(['employee', 'admin', 'super_admin']).default('employee'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshSchema = z.object({
|
const refreshSchema = z.object({
|
||||||
@@ -24,14 +24,7 @@ const refreshSchema = z.object({
|
|||||||
// ──────────────────────────── Entries ────────────────────────────
|
// ──────────────────────────── Entries ────────────────────────────
|
||||||
|
|
||||||
const createEntrySchema = z.object({
|
const createEntrySchema = z.object({
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||||
.refine(val => {
|
|
||||||
// Allow today + past dates. Compare date-only (ignore time).
|
|
||||||
// Use T12:00 to avoid timezone edge cases.
|
|
||||||
const today = new Date();
|
|
||||||
const todayStr = today.toISOString().split('T')[0];
|
|
||||||
return val <= todayStr;
|
|
||||||
}, { message: 'Cannot create entries for future dates' }),
|
|
||||||
homeownerId: z.string().uuid('Invalid homeowner ID'),
|
homeownerId: z.string().uuid('Invalid homeowner ID'),
|
||||||
hoursWorked: z
|
hoursWorked: z
|
||||||
.number()
|
.number()
|
||||||
@@ -76,23 +69,6 @@ const approveRejectSchema = z.object({
|
|||||||
notes: z.string().max(1000).trim().optional(),
|
notes: z.string().max(1000).trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bulkOperationSchema = z.object({
|
|
||||||
timesheetIds: z.array(z.string().uuid('Invalid timesheet ID')).min(1, 'At least one timesheet ID required').max(100, 'Maximum 100 timesheets per batch'),
|
|
||||||
notes: z.string().max(1000).trim().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateUserSchema = z.object({
|
|
||||||
name: z.string().min(1).max(100).trim().optional(),
|
|
||||||
role: z.enum(['employee', 'office', 'admin', 'super_admin']).optional(),
|
|
||||||
isActive: z.boolean().optional(),
|
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters').max(128).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyWeekSchema = z.object({
|
|
||||||
fromWeek: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fromWeek must be YYYY-MM-DD'),
|
|
||||||
toWeek: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'toWeek must be YYYY-MM-DD'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createUserSchema = registerSchema;
|
const createUserSchema = registerSchema;
|
||||||
|
|
||||||
const createHomeownerSchema = z.object({
|
const createHomeownerSchema = z.object({
|
||||||
@@ -114,47 +90,19 @@ const reportQuerySchema = z.object({
|
|||||||
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────── Route Params ────────────────────────────
|
|
||||||
|
|
||||||
const uuidParamSchema = z.object({
|
|
||||||
id: z.string().uuid('Invalid ID format'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──────────────────────────── Admin Query Params ────────────────────────────
|
|
||||||
|
|
||||||
const adminTimesheetQuerySchema = z.object({
|
|
||||||
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
|
||||||
userId: z.string().uuid().optional(),
|
|
||||||
page: z.string().regex(/^\d+$/).optional(),
|
|
||||||
limit: z.string().regex(/^\d+$/).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOvertimeQuerySchema = 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(),
|
|
||||||
threshold: z.string().regex(/^\d+(\.\d+)?$/).optional(),
|
|
||||||
rate: z.string().regex(/^\d+(\.\d+)?$/).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminReportPdfQuerySchema = z.object({
|
|
||||||
userId: z.string().uuid('Invalid userId'),
|
|
||||||
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ──────────────────────────── Profile / Password Reset ────────────────────────────
|
// ──────────────────────────── Profile / Password Reset ────────────────────────────
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
currentPassword: z.string().min(1, 'Current password is required').max(128),
|
currentPassword: z.string().min(1, "Current password is required").max(128),
|
||||||
newEmail: z.string().email('Invalid email address').max(255).optional(),
|
newEmail: z.string().email("Invalid email address").max(255).optional(),
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters').max(128).optional(),
|
newPassword: z.string().min(8, "Password must be at least 8 characters").max(128).optional(),
|
||||||
}).refine((data) => data.newEmail || data.newPassword, {
|
}).refine((data) => data.newEmail || data.newPassword, {
|
||||||
message: 'At least one of newEmail or newPassword must be provided',
|
message: "At least one of newEmail or newPassword must be provided",
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetPasswordSchema = z.object({
|
const resetPasswordSchema = z.object({
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters').max(128),
|
newPassword: z.string().min(8, "Password must be at least 8 characters").max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────── Helpers ────────────────────────────
|
// ──────────────────────────── Helpers ────────────────────────────
|
||||||
@@ -195,17 +143,6 @@ function validateQuery(schema) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware for validating :id route param as UUID
|
|
||||||
*/
|
|
||||||
function validateIdParam(req, res, next) {
|
|
||||||
const result = uuidParamSchema.safeParse(req.params);
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(400).json({ error: 'Invalid ID format — expected UUID' });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
@@ -216,19 +153,12 @@ module.exports = {
|
|||||||
submitTimesheetSchema,
|
submitTimesheetSchema,
|
||||||
emailTimesheetSchema,
|
emailTimesheetSchema,
|
||||||
approveRejectSchema,
|
approveRejectSchema,
|
||||||
bulkOperationSchema,
|
|
||||||
updateUserSchema,
|
|
||||||
copyWeekSchema,
|
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
createHomeownerSchema,
|
createHomeownerSchema,
|
||||||
updateHomeownerSchema,
|
updateHomeownerSchema,
|
||||||
reportQuerySchema,
|
reportQuerySchema,
|
||||||
adminTimesheetQuerySchema,
|
|
||||||
adminOvertimeQuerySchema,
|
|
||||||
adminReportPdfQuerySchema,
|
|
||||||
updateProfileSchema,
|
updateProfileSchema,
|
||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
validateIdParam,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
DB_PASSWORD=coastal_secret
|
|
||||||
JWT_SECRET=Ts8pLm3QvK5nRw9sYzB4jFc7hE0aGd2U
|
|
||||||
JWT_REFRESH_SECRET=Rf6kMn2QpT8wLs4vYzA7jFb9hD0eGc5U
|
|
||||||
NODE_ENV=production
|
|
||||||
CORS_ORIGINS=https://ts.bizzle.cloud,http://localhost:5173,http://localhost:3000
|
|
||||||
ENABLE_BACKUPS=false
|
|
||||||
@@ -41,9 +41,6 @@ COPY --from=builder /app/prisma ./prisma
|
|||||||
COPY --from=builder /app/src ./src
|
COPY --from=builder /app/src ./src
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
# Fix ownership so non-root user can regenerate Prisma client
|
|
||||||
RUN chown -R coastal:coastal /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER coastal
|
USER coastal
|
||||||
|
|
||||||
@@ -55,4 +52,4 @@ 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))"
|
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
|
# Start with migration and seed on first run
|
||||||
CMD ["sh", "-c", "npx prisma db push 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"]
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"]
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
name: timesheet
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ─── PostgreSQL ─────────────────────────────────────────
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: timesheet-db
|
container_name: coastal-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: coastal
|
POSTGRES_USER: coastal
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret}
|
||||||
POSTGRES_DB: coastal_timesheet
|
POSTGRES_DB: coastal_timesheet
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:5432:5432'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet']
|
test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -19,27 +22,24 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- coastal
|
- coastal
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256M
|
|
||||||
|
|
||||||
|
# ─── Backend API ────────────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: docker/backend/Dockerfile
|
dockerfile: docker/backend/Dockerfile
|
||||||
container_name: timesheet-backend
|
container_name: coastal-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:?DB_PASSWORD must be set}@db:5432/coastal_timesheet
|
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026}
|
||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?JWT_REFRESH_SECRET must be set in .env}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026}
|
||||||
PORT: '3004'
|
PORT: '3001'
|
||||||
NODE_ENV: production
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
CORS_ORIGINS: https://ts.bizzle.cloud,http://localhost:5173,http://localhost:3000
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://100.94.106.120:8080}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
@@ -47,32 +47,28 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:3005:3004'
|
- '127.0.0.1:3001:3001'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'node', '-e', "fetch('http://localhost:3004/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
|
test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- coastal
|
- coastal
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256M
|
|
||||||
|
|
||||||
|
# ─── Frontend (Nginx) ──────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: docker/frontend/Dockerfile
|
dockerfile: docker/frontend/Dockerfile
|
||||||
container_name: timesheet-frontend
|
container_name: coastal-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:8083:80'
|
- '100.94.106.120:8080:80'
|
||||||
- '172.18.0.1:8083:80'
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -83,15 +79,10 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- coastal
|
- coastal
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128M
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
external: true
|
driver: local
|
||||||
name: coastal_pgdata
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
coastal:
|
coastal:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# /* → frontend static files
|
# /* → frontend static files
|
||||||
|
|
||||||
upstream backend_api {
|
upstream backend_api {
|
||||||
server backend:3004;
|
server backend:3001;
|
||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,11 +11,12 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name _;
|
server_name _;
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
# Security headers (X-Frame-Options, HSTS, X-Content-Type-Options set by Caddy)
|
# Security headers
|
||||||
add_header X-XSS-Protection "0" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 compression
|
||||||
gzip on;
|
gzip on;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 668 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 781 KiB After Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
@@ -5,21 +5,12 @@
|
|||||||
<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>" />
|
<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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" />
|
||||||
<meta name="theme-color" content="#0ea5e9" />
|
<meta name="theme-color" content="#0ea5e9" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>Coastal Contracting Timesheet</title>
|
<title>Coastal Timesheet</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 dark:bg-gray-950 antialiased">
|
<body class="bg-gray-50 dark:bg-gray-950 antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
<script>
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#0ea5e9"/>
|
|
||||||
<stop offset="100%" stop-color="#0369a1"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="192" height="192" rx="28" fill="url(#bg)"/>
|
|
||||||
<text x="96" y="108" text-anchor="middle" font-size="100" fill="white" font-family="serif">🌊</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 453 B |
@@ -1,10 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#0ea5e9"/>
|
|
||||||
<stop offset="100%" stop-color="#0369a1"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" rx="76" fill="url(#bg)"/>
|
|
||||||
<text x="256" y="290" text-anchor="middle" font-size="260" fill="white" font-family="serif">🌊</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 454 B |
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Coastal Contracting Timesheet",
|
|
||||||
"short_name": "Timesheet",
|
|
||||||
"description": "Employee timesheet management for Coastal Contracting",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#0f172a",
|
|
||||||
"theme_color": "#0ea5e9",
|
|
||||||
"orientation": "any",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192.svg",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.svg",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
const CACHE_NAME = 'coastal-timesheet-v1';
|
|
||||||
const STATIC_ASSETS = [
|
|
||||||
'/',
|
|
||||||
'/manifest.json',
|
|
||||||
'/icons/icon-192.svg',
|
|
||||||
'/icons/icon-512.svg',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Install: cache shell
|
|
||||||
self.addEventListener('install', (e) => {
|
|
||||||
e.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate: clean old caches
|
|
||||||
self.addEventListener('activate', (e) => {
|
|
||||||
e.waitUntil(
|
|
||||||
caches.keys().then((keys) =>
|
|
||||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch: network-first for API, cache-first for assets
|
|
||||||
self.addEventListener('fetch', (e) => {
|
|
||||||
const { request } = e;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
// Skip non-GET
|
|
||||||
if (request.method !== 'GET') return;
|
|
||||||
|
|
||||||
// API calls: network-first with offline fallback
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
e.respondWith(
|
|
||||||
fetch(request)
|
|
||||||
.then((res) => {
|
|
||||||
// Cache successful GET API responses for offline use
|
|
||||||
if (res.ok) {
|
|
||||||
const clone = res.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => caches.match(request).then((cached) => cached || offlineResponse()))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static assets: cache-first
|
|
||||||
e.respondWith(
|
|
||||||
caches.match(request).then((cached) => {
|
|
||||||
if (cached) {
|
|
||||||
// Background refresh
|
|
||||||
fetch(request).then((res) => {
|
|
||||||
if (res.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, res));
|
|
||||||
}).catch(() => {});
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
return fetch(request).then((res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
const clone = res.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}).catch(() => caches.match('/'));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function offlineResponse() {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'offline', message: 'You are currently offline. Data will sync when connection is restored.' }),
|
|
||||||
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,35 +18,11 @@ function getRefreshToken() {
|
|||||||
return localStorage.getItem('refreshToken');
|
return localStorage.getItem('refreshToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJwt(token) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(atob(token.split('.')[1]));
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTokens(accessToken, refreshToken) {
|
function setTokens(accessToken, refreshToken) {
|
||||||
localStorage.setItem('accessToken', accessToken);
|
localStorage.setItem('accessToken', accessToken);
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
localStorage.setItem('refreshToken', refreshToken);
|
localStorage.setItem('refreshToken', refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the 'user' object in localStorage with the data from the new access token
|
|
||||||
const payload = parseJwt(accessToken);
|
|
||||||
if (payload) {
|
|
||||||
const existingUser = JSON.parse(localStorage.getItem('user') || '{}');
|
|
||||||
// Ensure we don't overwrite user-specific static data if missing from JWT
|
|
||||||
const updatedUser = { ...existingUser, ...payload };
|
|
||||||
// The user ID usually comes as 'sub' in JWT, but frontend expects 'id'
|
|
||||||
if (payload.sub && !updatedUser.id) updatedUser.id = payload.sub;
|
|
||||||
|
|
||||||
// Explicitly update role and email which are crucial for the UI
|
|
||||||
if (payload.role) updatedUser.role = payload.role;
|
|
||||||
if (payload.email) updatedUser.email = payload.email;
|
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTokens() {
|
function clearTokens() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import EntryForm from './EntryForm';
|
|||||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) {
|
export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded, onHomeownerAdded }) {
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
// Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift)
|
// Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift)
|
||||||
const [y, m, dy] = date.split('-').map(Number);
|
const [y, m, dy] = date.split('-').map(Number);
|
||||||
@@ -65,9 +65,10 @@ export default function DayCard({ date, entries, homeowners, onEntryChange, onAd
|
|||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="px-4 pb-4 space-y-3">
|
||||||
{(entries || []).map((entry) => (
|
{(entries || []).map((entry) => (
|
||||||
<EntryForm
|
<EntryForm
|
||||||
key={entry.id}
|
key={entry.clientKey || entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
homeowners={homeowners}
|
homeowners={homeowners}
|
||||||
|
onHomeownerAdded={onHomeownerAdded}
|
||||||
onChange={onEntryChange}
|
onChange={onEntryChange}
|
||||||
onDelete={onDeleteEntry}
|
onDelete={onDeleteEntry}
|
||||||
canDelete={(entries || []).length > 1}
|
canDelete={(entries || []).length > 1}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import HomeownerSelect from './HomeownerSelect';
|
import HomeownerSelect from './HomeownerSelect';
|
||||||
|
|
||||||
export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) {
|
export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled, onHomeownerAdded }) {
|
||||||
function handleChange(field, value) {
|
function handleChange(field, value) {
|
||||||
onChange(entry.id, { ...entry, [field]: value });
|
onChange(entry.id, { ...entry, [field]: value });
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ export default function EntryForm({ entry, homeowners, onChange, onDelete, canDe
|
|||||||
Homeowner {isPartial && !entry.homeownerId && <span className="text-red-500">*</span>}
|
Homeowner {isPartial && !entry.homeownerId && <span className="text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
<HomeownerSelect
|
<HomeownerSelect
|
||||||
|
onHomeownerAdded={onHomeownerAdded}
|
||||||
homeowners={homeowners}
|
homeowners={homeowners}
|
||||||
value={entry.homeownerId || ''}
|
value={entry.homeownerId || ''}
|
||||||
onChange={(v) => handleChange('homeownerId', v)}
|
onChange={(v) => handleChange('homeownerId', v)}
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ChevronDown, Plus, Search } from 'lucide-react';
|
import { ChevronDown, Plus, Search, Check, X } from 'lucide-react';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
export default function HomeownerSelect({ homeowners, value, onChange, disabled }) {
|
export default function HomeownerSelect({ homeowners, value, onChange, onHomeownerAdded, disabled }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
const newNameRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
if (ref.current && !ref.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
setAdding(false);
|
||||||
|
setNewName('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener('mousedown', handleClick);
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && inputRef.current) inputRef.current.focus();
|
if (open && !adding && inputRef.current) inputRef.current.focus();
|
||||||
}, [open]);
|
}, [open, adding]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adding && newNameRef.current) newNameRef.current.focus();
|
||||||
|
}, [adding]);
|
||||||
|
|
||||||
const filtered = homeowners.filter((h) =>
|
const filtered = homeowners.filter((h) =>
|
||||||
h.name.toLowerCase().includes(search.toLowerCase())
|
h.name.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -26,6 +40,27 @@ export default function HomeownerSelect({ homeowners, value, onChange, disabled
|
|||||||
|
|
||||||
const selectedLabel = homeowners.find((h) => h.id === value)?.name || '';
|
const selectedLabel = homeowners.find((h) => h.id === value)?.name || '';
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api.post('/homeowners', { name: newName.trim() });
|
||||||
|
const ho = res.data.homeowner;
|
||||||
|
if (onHomeownerAdded) onHomeownerAdded(ho);
|
||||||
|
onChange(ho.id);
|
||||||
|
setOpen(false);
|
||||||
|
setAdding(false);
|
||||||
|
setNewName('');
|
||||||
|
setSearch('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to add homeowner');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
@@ -43,48 +78,62 @@ export default function HomeownerSelect({ homeowners, value, onChange, disabled
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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="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-72 overflow-hidden">
|
||||||
|
{!adding ? (
|
||||||
|
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAdding(true); setSearch(''); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-sky-600 dark:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add Homeowner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<form onSubmit={handleAdd} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => { setNewName(e.target.value); setError(''); }}
|
||||||
|
placeholder="Homeowner name..."
|
||||||
|
className="flex-1 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-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={saving || !newName.trim()} className="p-2 rounded-lg bg-sky-500 hover:bg-sky-600 text-white disabled:opacity-40 transition-colors" title="Save">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setAdding(false); setNewName(''); setError(''); }} className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 transition-colors" title="Cancel">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="mt-1 text-xs text-red-500 px-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!adding && (
|
||||||
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<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" />
|
||||||
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>
|
</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
|
<div className="overflow-y-auto max-h-48 p-1">
|
||||||
key={h.id}
|
{value && !adding && (
|
||||||
type="button"
|
<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>
|
||||||
onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
|
)}
|
||||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
{!adding && filtered.map((h) => (
|
||||||
h.id === value
|
<button key={h.id} type="button" onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
|
||||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium'
|
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'}`}>
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{h.name}
|
{h.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && search && !adding && (
|
{!adding && filtered.length === 0 && search && (
|
||||||
<div className="px-3 py-4 text-center">
|
<div className="px-3 py-4 text-center"><p className="text-sm text-gray-400">No match for "{search}"</p></div>
|
||||||
<p className="text-sm text-gray-400 mb-2">No match found</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function Layout() {
|
|||||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<span className="text-lg font-semibold bg-gradient-to-r from-sky-500 to-teal-400 bg-clip-text text-transparent">
|
||||||
Coastal Contracting
|
Coastal
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
|
||||||
|
|
||||||
export default function OfflineBanner() {
|
|
||||||
const isOnline = useOnlineStatus();
|
|
||||||
if (isOnline) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-amber-500 text-white text-center text-sm py-1.5 px-4 font-medium sticky top-0 z-50">
|
|
||||||
⚡ You're offline — viewing cached data. Changes will sync when reconnected.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
|
import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
@@ -14,17 +13,6 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.get('/auth/me');
|
|
||||||
setUser(data.user);
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
} catch {
|
|
||||||
/* If /me fails, we might be logged out or server is down */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* On mount, verify the stored token is still valid */
|
/* On mount, verify the stored token is still valid */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,13 +36,6 @@ export function AuthProvider({ children }) {
|
|||||||
verify();
|
verify();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* Refresh user profile on navigation to ensure role is up to date */
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
refreshUser();
|
|
||||||
}
|
|
||||||
}, [location.pathname, user?.id]);
|
|
||||||
|
|
||||||
const login = useCallback(async (email, password) => {
|
const login = useCallback(async (email, password) => {
|
||||||
const { data } = await api.post('/auth/login', { email, password });
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
setTokens(data.accessToken, data.refreshToken);
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
@@ -73,7 +54,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isAdmin = ['office', 'admin', 'super_admin'].includes(user?.role);
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function useOnlineStatus() {
|
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const goOnline = () => setIsOnline(true);
|
|
||||||
const goOffline = () => setIsOnline(false);
|
|
||||||
window.addEventListener('online', goOnline);
|
|
||||||
window.addEventListener('offline', goOffline);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', goOnline);
|
|
||||||
window.removeEventListener('offline', goOffline);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isOnline;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import Timesheet from './pages/Timesheet';
|
|||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
@@ -43,7 +42,6 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<OfflineBanner />
|
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle, Key, } from 'lucide-react';
|
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle, Key } from 'lucide-react';
|
||||||
|
|
||||||
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||||
return (
|
return (
|
||||||
@@ -193,7 +192,6 @@ function PendingReviews() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManageUsers() {
|
function ManageUsers() {
|
||||||
const { user } = useAuth();
|
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -264,8 +262,7 @@ function ManageUsers() {
|
|||||||
<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" />
|
<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">
|
<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="employee">Employee</option>
|
||||||
<option value="office">Office Staff</option>
|
<option value="admin">Admin</option>
|
||||||
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
@@ -296,23 +293,8 @@ function ManageUsers() {
|
|||||||
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"
|
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="employee">Employee</option>
|
||||||
<option value="office">Office Staff</option>
|
<option value="admin">Admin</option>
|
||||||
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
|
|
||||||
</select>
|
</select>
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const newPass = prompt(`Reset password for ${u.name}?\nEnter new password (min 8 characters):`);
|
|
||||||
if (!newPass || newPass.length < 8) { if (newPass) alert('Password must be at least 8 characters'); return; }
|
|
||||||
try {
|
|
||||||
await api.put(`/admin/users/${u.id}/reset-password`, { newPassword: newPass });
|
|
||||||
alert(`Password reset for ${u.name}. They can now log in with the new password.`);
|
|
||||||
} catch (err) { alert(err.response?.data?.error || 'Failed to reset password'); }
|
|
||||||
}}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-500/20 font-medium"
|
|
||||||
title="Reset Password"
|
|
||||||
>
|
|
||||||
🔑 Reset PW
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setResetUserId(resetUserId === u.id ? null : u.id); setResetPw(''); }}
|
onClick={() => { setResetUserId(resetUserId === u.id ? null : u.id); setResetPw(''); }}
|
||||||
className="p-1.5 rounded-lg text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-500/10 transition-all"
|
className="p-1.5 rounded-lg text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-500/10 transition-all"
|
||||||
@@ -535,6 +517,17 @@ function Reports() {
|
|||||||
}).catch(console.error).finally(() => setLoading(false));
|
}).catch(console.error).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function unlockTimesheet(id) {
|
||||||
|
if (!confirm('Unlock this timesheet so the employee can edit it?')) return;
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/timesheets/${id}/reopen`);
|
||||||
|
setTimesheets((prev) => prev.map((t) => t.id === id ? { ...t, status: 'draft' } : t));
|
||||||
|
if (selectedTs?.id === id) setSelectedTs((prev) => ({ ...prev, status: 'draft' }));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to unlock timesheet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyFilters() {
|
async function applyFilters() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -665,9 +658,17 @@ function Reports() {
|
|||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<StatusBadge status={selectedTs.status} />
|
<StatusBadge status={selectedTs.status} />
|
||||||
<span className="text-sm text-gray-500">{fmtDate(selectedTs.weekStart)} — {fmtDate(selectedTs.weekEnd)}</span>
|
<span className="text-sm text-gray-500">{fmtDate(selectedTs.weekStart)} — {fmtDate(selectedTs.weekEnd)}</span>
|
||||||
|
{(selectedTs.status === 'submitted' || selectedTs.status === 'approved') && (
|
||||||
|
<button
|
||||||
|
onClick={() => unlockTimesheet(selectedTs.id)}
|
||||||
|
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-semibold hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-all"
|
||||||
|
>
|
||||||
|
🔓 Unlock for Editing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
@@ -794,7 +795,6 @@ function OvertimeReport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
|
||||||
const [tab, setTab] = useState('reviews');
|
const [tab, setTab] = useState('reviews');
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import api from '../api/client';
|
import api from "../api/client";
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { Mail, Lock, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Mail, Lock, Loader2, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [emailForm, setEmailForm] = useState({ currentPassword: '', newEmail: '' });
|
const [emailForm, setEmailForm] = useState({ currentPassword: "", newEmail: "" });
|
||||||
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
const [pwForm, setPwForm] = useState({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
const [emailLoading, setEmailLoading] = useState(false);
|
const [emailLoading, setEmailLoading] = useState(false);
|
||||||
const [pwLoading, setPwLoading] = useState(false);
|
const [pwLoading, setPwLoading] = useState(false);
|
||||||
const [emailMsg, setEmailMsg] = useState(null);
|
const [emailMsg, setEmailMsg] = useState(null);
|
||||||
@@ -18,14 +18,14 @@ export default function Profile() {
|
|||||||
setEmailMsg(null);
|
setEmailMsg(null);
|
||||||
setEmailLoading(true);
|
setEmailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.put('/auth/profile', {
|
const res = await api.put("/auth/profile", {
|
||||||
currentPassword: emailForm.currentPassword,
|
currentPassword: emailForm.currentPassword,
|
||||||
newEmail: emailForm.newEmail,
|
newEmail: emailForm.newEmail,
|
||||||
});
|
});
|
||||||
setEmailMsg({ type: 'success', text: `Email updated to ${res.data.user.email}` });
|
setEmailMsg({ type: "success", text: `Email updated to ${res.data.user.email}` });
|
||||||
setEmailForm({ currentPassword: '', newEmail: '' });
|
setEmailForm({ currentPassword: "", newEmail: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEmailMsg({ type: 'error', text: err.response?.data?.error || 'Failed to update email' });
|
setEmailMsg({ type: "error", text: err.response?.data?.error || "Failed to update email" });
|
||||||
} finally {
|
} finally {
|
||||||
setEmailLoading(false);
|
setEmailLoading(false);
|
||||||
}
|
}
|
||||||
@@ -35,26 +35,26 @@ export default function Profile() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPwMsg(null);
|
setPwMsg(null);
|
||||||
if (pwForm.newPassword !== pwForm.confirmPassword) {
|
if (pwForm.newPassword !== pwForm.confirmPassword) {
|
||||||
setPwMsg({ type: 'error', text: 'New passwords do not match' });
|
setPwMsg({ type: "error", text: "New passwords do not match" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPwLoading(true);
|
setPwLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.put('/auth/profile', {
|
await api.put("/auth/profile", {
|
||||||
currentPassword: pwForm.currentPassword,
|
currentPassword: pwForm.currentPassword,
|
||||||
newPassword: pwForm.newPassword,
|
newPassword: pwForm.newPassword,
|
||||||
});
|
});
|
||||||
setPwMsg({ type: 'success', text: 'Password updated successfully' });
|
setPwMsg({ type: "success", text: "Password updated successfully" });
|
||||||
setPwForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
setPwForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPwMsg({ type: 'error', text: err.response?.data?.error || 'Failed to update password' });
|
setPwMsg({ type: "error", text: err.response?.data?.error || "Failed to update password" });
|
||||||
} finally {
|
} finally {
|
||||||
setPwLoading(false);
|
setPwLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'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 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500';
|
"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 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-lg mx-auto">
|
<div className="space-y-6 max-w-lg mx-auto">
|
||||||
@@ -72,8 +72,8 @@ export default function Profile() {
|
|||||||
Change Email
|
Change Email
|
||||||
</div>
|
</div>
|
||||||
{emailMsg && (
|
{emailMsg && (
|
||||||
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${emailMsg.type === 'success' ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400'}`}>
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${emailMsg.type === "success" ? "bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400"}`}>
|
||||||
{emailMsg.type === 'success' ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
{emailMsg.type === "success" ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
{emailMsg.text}
|
{emailMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -112,8 +112,8 @@ export default function Profile() {
|
|||||||
Change Password
|
Change Password
|
||||||
</div>
|
</div>
|
||||||
{pwMsg && (
|
{pwMsg && (
|
||||||
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${pwMsg.type === 'success' ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400'}`}>
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${pwMsg.type === "success" ? "bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400"}`}>
|
||||||
{pwMsg.type === 'success' ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
{pwMsg.type === "success" ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
{pwMsg.text}
|
{pwMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,15 +36,38 @@ export default function Timesheet() {
|
|||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
const [entries, setEntries] = useState({});
|
const [entries, setEntries] = useState({});
|
||||||
const [homeowners, setHomeowners] = useState([]);
|
const [homeowners, setHomeowners] = useState([]);
|
||||||
|
|
||||||
|
// Handler when a new homeowner is added via the dropdown
|
||||||
|
function handleHomeownerAdded(newHomeowner) {
|
||||||
|
setHomeowners((prev) => {
|
||||||
|
if (prev.find((h) => h.id === newHomeowner.id)) return prev;
|
||||||
|
return [...prev, newHomeowner].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [timesheet, setTimesheet] = useState(null);
|
const [timesheet, setTimesheet] = useState(null);
|
||||||
const [saveStatus, setSaveStatus] = useState('saved');
|
const [saveStatus, setSaveStatus] = useState('saved');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
|
const [emailSending, setEmailSending] = useState(false);
|
||||||
|
const [emailStatus, setEmailStatus] = useState(null);
|
||||||
|
const [emailConfirm, setEmailConfirm] = useState(false);
|
||||||
|
const [emailResult, setEmailResult] = useState(null);
|
||||||
const [overtime, setOvertime] = useState(null);
|
const [overtime, setOvertime] = useState(null);
|
||||||
const saveTimer = useRef(null);
|
const saveTimer = useRef(null);
|
||||||
const pendingChanges = useRef({});
|
const pendingChanges = useRef({});
|
||||||
|
// Client-created entries keep a temp id until the server assigns a real one.
|
||||||
|
// idMap: tempId -> realId (after create resolves)
|
||||||
|
// creating: tempId -> Promise<realId> (while create is in flight)
|
||||||
|
const idMap = useRef({});
|
||||||
|
const creating = useRef({});
|
||||||
|
|
||||||
|
function makeNewEntry(date) {
|
||||||
|
const id = `new-${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
return { id, clientKey: id, date, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
const weekDates = getWeekDates(selectedDate);
|
const weekDates = getWeekDates(selectedDate);
|
||||||
const weekParam = formatWeekParam(selectedDate);
|
const weekParam = formatWeekParam(selectedDate);
|
||||||
@@ -72,10 +95,12 @@ export default function Timesheet() {
|
|||||||
// Ensure at least one empty entry per day
|
// Ensure at least one empty entry per day
|
||||||
weekDates.forEach((d) => {
|
weekDates.forEach((d) => {
|
||||||
if (byDate[d].length === 0) {
|
if (byDate[d].length === 0) {
|
||||||
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
byDate[d] = [makeNewEntry(d)];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
idMap.current = {};
|
||||||
|
creating.current = {};
|
||||||
setEntries(byDate);
|
setEntries(byDate);
|
||||||
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
||||||
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
||||||
@@ -111,18 +136,51 @@ export default function Timesheet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (data._isNew || entryId.startsWith('new-')) {
|
const isTemp = entryId.startsWith('new-');
|
||||||
const res = await api.post('/entries', payload);
|
let realId = isTemp ? idMap.current[entryId] : entryId;
|
||||||
// Replace temp ID with real ID
|
|
||||||
|
// A create for this temp id is already in flight — wait for it, then update.
|
||||||
|
if (isTemp && !realId && creating.current[entryId]) {
|
||||||
|
realId = await creating.current[entryId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTemp && !realId) {
|
||||||
|
const createPromise = api.post('/entries', payload).then((res) => {
|
||||||
|
const created = res.data.entry || res.data;
|
||||||
|
idMap.current[entryId] = created.id;
|
||||||
|
// Merge the server id into the existing local entry. Keep the
|
||||||
|
// clientKey (so React does not remount the form) and keep whatever
|
||||||
|
// the user has typed since the request was sent.
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const dk = data.date;
|
const dk = data.date;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
|
[dk]: (prev[dk] || []).map((e) =>
|
||||||
|
e.id === entryId
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
id: created.id,
|
||||||
|
clientKey: e.clientKey,
|
||||||
|
date: dk,
|
||||||
|
homeownerId: e.homeownerId,
|
||||||
|
hoursWorked: e.hoursWorked,
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
_isNew: false,
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return created.id;
|
||||||
|
});
|
||||||
|
creating.current[entryId] = createPromise;
|
||||||
|
try {
|
||||||
|
await createPromise;
|
||||||
|
} finally {
|
||||||
|
delete creating.current[entryId];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await api.put(`/entries/${entryId}`, payload);
|
await api.put(`/entries/${realId}`, payload);
|
||||||
}
|
}
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -151,27 +209,27 @@ export default function Timesheet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAddEntry(date) {
|
function handleAddEntry(date) {
|
||||||
const newEntry = {
|
const newEntry = makeNewEntry(date);
|
||||||
id: `new-${date}-${Date.now()}`,
|
|
||||||
date,
|
|
||||||
homeownerId: '',
|
|
||||||
hoursWorked: '',
|
|
||||||
workDescription: '',
|
|
||||||
_isNew: true,
|
|
||||||
};
|
|
||||||
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteEntry(entryId) {
|
async function handleDeleteEntry(entryId) {
|
||||||
try {
|
try {
|
||||||
if (!entryId.startsWith('new-')) {
|
// Resolve a temp id to its server id if the create already happened
|
||||||
await api.delete(`/entries/${entryId}`);
|
// (or is still in flight) so we do not orphan a row on the server.
|
||||||
|
let realId = entryId;
|
||||||
|
if (entryId.startsWith('new-')) {
|
||||||
|
realId = idMap.current[entryId] || (creating.current[entryId] ? await creating.current[entryId] : null);
|
||||||
}
|
}
|
||||||
|
if (realId) {
|
||||||
|
await api.delete(`/entries/${realId}`);
|
||||||
|
}
|
||||||
|
delete pendingChanges.current[entryId];
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const updated = {};
|
const updated = {};
|
||||||
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
||||||
const filtered = dayEntries.filter((e) => e.id !== entryId);
|
const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId);
|
||||||
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)];
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
@@ -226,6 +284,31 @@ export default function Timesheet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadEmailStatus() {
|
||||||
|
if (!timesheetId) return;
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/timesheets/${timesheetId}/email-status`);
|
||||||
|
setEmailStatus(res.data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendEmail() {
|
||||||
|
if (!timesheetId) return;
|
||||||
|
setEmailSending(true);
|
||||||
|
setEmailResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/timesheets/${timesheetId}/email`);
|
||||||
|
setEmailStatus(s => ({ ...s, count: res.data.count, allowed: res.data.remaining > 0, cooldownMs: 30 * 60 * 1000 }));
|
||||||
|
setEmailResult({ ok: true, msg: 'Sent to Office!' });
|
||||||
|
setEmailConfirm(false);
|
||||||
|
} catch (err) {
|
||||||
|
setEmailResult({ ok: false, msg: err.response?.data?.error || 'Failed to send email' });
|
||||||
|
setEmailConfirm(false);
|
||||||
|
} finally {
|
||||||
|
setEmailSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDownloadPdf() {
|
async function handleDownloadPdf() {
|
||||||
if (!timesheetId) return;
|
if (!timesheetId) return;
|
||||||
setPdfLoading(true);
|
setPdfLoading(true);
|
||||||
@@ -310,6 +393,7 @@ export default function Timesheet() {
|
|||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
defaultExpanded={date === today}
|
defaultExpanded={date === today}
|
||||||
|
onHomeownerAdded={handleHomeownerAdded}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,6 +410,7 @@ export default function Timesheet() {
|
|||||||
{submitting ? 'Submitting...' : 'Submit Timesheet'}
|
{submitting ? 'Submitting...' : 'Submit Timesheet'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
@@ -335,19 +420,56 @@ export default function Timesheet() {
|
|||||||
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
||||||
Download PDF
|
Download PDF
|
||||||
</button>
|
</button>
|
||||||
|
{!emailConfirm ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { loadEmailStatus(); setEmailConfirm(true); setEmailResult(null); }}
|
||||||
if (timesheetId) {
|
disabled={emailSending}
|
||||||
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-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold flex items-center justify-center gap-2 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 transition-all"
|
||||||
}
|
|
||||||
}}
|
|
||||||
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} />
|
<Mail size={18} />
|
||||||
Email
|
Send to Office
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 rounded-xl border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/5 p-3 space-y-2">
|
||||||
|
<p className="text-xs text-emerald-700 dark:text-emerald-400 font-medium text-center">
|
||||||
|
Send to Office@CoastalContractingFL.com?
|
||||||
|
</p>
|
||||||
|
{emailStatus && !emailStatus.allowed ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled className="flex-1 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-semibold opacity-40 flex items-center justify-center gap-1">
|
||||||
|
<Mail size={12} />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEmailConfirm(false); setEmailResult(null); }} className="flex-1 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs font-semibold">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={emailSending}
|
||||||
|
className="flex-1 py-1.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-semibold disabled:opacity-50 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{emailSending ? <Loader2 size={12} className="animate-spin" /> : <Mail size={12} />}
|
||||||
|
{emailSending ? 'Sending...' : 'Yes, Send'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailConfirm(false); setEmailResult(null); }}
|
||||||
|
className="flex-1 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{emailResult && (
|
||||||
|
<p className={`text-xs text-center font-medium ${emailResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
|
||||||
|
{emailResult.msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{timesheet?.status === 'rejected' && timesheet?.notes && (
|
{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">
|
<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">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 210 KiB |