From 023f0394b4c5cfdd5977ac779ba9d291f03b33ca Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 23 Aug 2026 00:35:51 +0000 Subject: [PATCH] initial: pulsy from pulse-clock-main --- .dockerignore | 18 + .editorconfig | 9 + .env.example | 3 + .gitignore | 57 + .prettierignore | 6 + .prettierrc.json | 6 + Dockerfile | 37 + README.md | 317 ++ drizzle.config.ts | 10 + drizzle/0000_chubby_toro.sql | 44 + drizzle/meta/0000_snapshot.json | 295 + drizzle/meta/_journal.json | 13 + eslint.config.mjs | 34 + next.config.ts | 20 + package-lock.json | 5989 ++++++++++++++++++++ package.json | 48 + postcss.config.mjs | 7 + scripts/test-calculations.mjs | 96 + src/app/admin/layout.tsx | 10 + src/app/admin/manage/page.tsx | 432 ++ src/app/admin/page.tsx | 865 +++ src/app/admin/payroll/page.tsx | 466 ++ src/app/api/authorize/route.ts | 37 + src/app/api/companies/route.ts | 66 + src/app/api/employees/route.ts | 83 + src/app/api/employees/with-status/route.ts | 63 + src/app/api/health/route.ts | 5 + src/app/api/payroll/route.ts | 131 + src/app/api/punch/route.ts | 42 + src/app/api/time-entries/route.ts | 135 + src/app/globals.css | 2 + src/app/kiosk/[companyId]/page.tsx | 390 ++ src/app/kiosk/layout.tsx | 23 + src/app/kiosk/page.tsx | 5 + src/app/layout.tsx | 33 + src/app/login/page.tsx | 100 + src/app/page.tsx | 5 + src/components/auth-gate.tsx | 143 + src/components/kiosk-zoom-lock.tsx | 33 + src/db/index.ts | 37 + src/db/schema.ts | 105 + src/lib/api-response.ts | 9 + src/lib/api.ts | 19 + src/lib/auth-cookie.ts | 61 + src/lib/auth.ts | 68 + src/lib/calculations.ts | 186 + src/lib/params.ts | 5 + src/lib/schemas.ts | 77 + src/lib/time-entry-service.ts | 117 + src/lib/time.ts | 58 + src/lib/validation.ts | 154 + src/proxy.ts | 165 + tsconfig.json | 32 + 53 files changed, 11171 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_chubby_toro.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 scripts/test-calculations.mjs create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/manage/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/payroll/page.tsx create mode 100644 src/app/api/authorize/route.ts create mode 100644 src/app/api/companies/route.ts create mode 100644 src/app/api/employees/route.ts create mode 100644 src/app/api/employees/with-status/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/payroll/route.ts create mode 100644 src/app/api/punch/route.ts create mode 100644 src/app/api/time-entries/route.ts create mode 100644 src/app/globals.css create mode 100644 src/app/kiosk/[companyId]/page.tsx create mode 100644 src/app/kiosk/layout.tsx create mode 100644 src/app/kiosk/page.tsx create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/auth-gate.tsx create mode 100644 src/components/kiosk-zoom-lock.tsx create mode 100644 src/db/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/lib/api-response.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth-cookie.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/calculations.ts create mode 100644 src/lib/params.ts create mode 100644 src/lib/schemas.ts create mode 100644 src/lib/time-entry-service.ts create mode 100644 src/lib/time.ts create mode 100644 src/lib/validation.ts create mode 100644 src/proxy.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d126cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.next +node_modules +coverage +data +logs +.env* +!.env.example +*.db +*.db-shm +*.db-wal +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +Dockerfile +docker-compose.yml +README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0c9d97 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Use at least 32 random characters. Generate with: openssl rand -base64 32 +PULSE_SECRET=replace-with-at-least-32-random-characters +DATABASE_URL=./data/pulse-clock.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5b65ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +/logs/ + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# local task files +.opencode-task.txt +.ralph/ +.hermes/ +CLAUDE.md +PROMPT.md +AGENTS.md + +# sqlite runtime files +/data/*.db +*.db-shm +*.db-wal diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..27d52fa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +dist/ +package-lock.json +data/ +next-env.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7cebf38 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "semi": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9949fb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV DATABASE_URL=/app/data/pulse-clock.db + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs \ + && mkdir -p /app/data \ + && chown -R nextjs:nodejs /app/data + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle + +VOLUME ["/app/data"] +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..99aa35e --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +# Pulse Clock + +Multi-tenant time clock and workforce management system for hotel/hospitality environments. Employees punch in/out and take breaks via a kiosk interface; administrators monitor time cards, detect scheduling issues, and export payroll data. + +## Overview + +`pulse-clock` provides: + +- **Employee Kiosk**: Web-based punch interface with webcam capture +- **Admin Dashboard**: Time grid view with issue detection +- **Management**: Hotels and employees CRUD +- **Payroll Export**: TSV download with validation checks +- **Validation Engine**: State machine enforcing valid punch sequences + +## Prerequisites + +- **Node.js** (v20.9 or higher) +- **npm** (package manager) +- **SQLite** (accessed through Drizzle and the libSQL client) + +## Installation + +```bash +cd pulse-clock +npm install +``` + +Copy the example environment file and configure: + +```bash +cp .env.example .env +``` + +Edit `.env` with your configuration. Key variables: + +| Variable | Description | Default | +| ------------------- | ------------------------------------ | ----------------------- | +| `PULSE_SECRET` | Login secret; use 32+ random chars | — | +| `PULSE_RATE_LIMIT` | API rate limit (requests per window) | `60` | +| `PULSE_RATE_WINDOW` | Rate limit window (seconds) | `60` | +| `DATABASE_URL` | SQLite database path | `./data/pulse-clock.db` | + +## Usage + +### Development Server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to access the application. + +### Build for Production + +```bash +npm run build +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Pulse Clock │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Kiosk Mode │ │ Admin Dashboard │ │ +│ │ /kiosk/* │ │ /admin/* │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ API Routes │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ /punch │ │ /time- │ │ /payroll │ │ │ +│ │ │ │ │ entries │ │ │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ time-entry-service.ts │ │ +│ │ (createValidatedTimeEntry) │ │ +│ │ Punch State Machine │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SQLite Database │ │ +│ │ companies | employees | time_entries | audit_log │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Kiosk Punch Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Employee Selects → Webcam Capture → Punch Button Click │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ POST /api/punch │ │ +│ │ { employeeId, type }│ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ Punch State Machine │ │ +│ │ validateNewEntry... │ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ┌─────────────────────┼──────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────┐ ┌───────────────┐ ┌──────────┐ +│ │ Valid Punch │ │ Invalid Punch │ │ Duplicate│ +│ │ → Save Entry │ │ → Reject │ │ → Ignore │ +│ └───────────────┘ └───────────────┘ └──────────┘ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Punch State Machine + +``` +Valid Transitions: + + OUT ──────▶ IN (clock in) + IN ──────▶ OUT (clock out) + IN ──────▶ BREAK_OUT (start break) + BREAK_OUT ─▶ BREAK_IN (end break) + +All other transitions are rejected unless forceOverride=true +``` + +## Key Features + +### Employee Kiosk + +- Live employee cards showing current status: **Off Duty** / **On Shift** / **On Break** +- Webcam capture on each punch (base64 stored in DB) +- Punch modal with Clock In, Clock Out, Start Break, End Break buttons +- 5-second duplicate-punch guard on kiosk clicks +- Auto-refreshes employee status every 5 seconds +- Elapsed shift time shown on employee cards (net of break time) + +### Admin Dashboard + +- Date range selector with Today/Yesterday/Last 7/Last 14/Last 30 presets +- Grid view: employees (rows) × calendar days (columns) +- Red cell highlighting for validation issues +- "Issues Only" filter toggle +- Detail drawer per employee/day: full punch timeline +- Timestamp editing through authenticated admin sessions +- Add manual punch entries + +### Validation Engine + +Detects per employee per day: + +- First entry is not Clock In +- Consecutive duplicate entries (e.g., two INs in a row) +- Break started without prior Clock In +- Break ended without break start +- Break started but never ended +- Still clocked in at day end +- Missing clock out + +### Payroll Export + +- Date range presets including Pay Period 1-15 and Pay Period 16-31 +- Preview table: Employee | Date | In | Out | Break (min) | Net Hours | Notes +- Copy to clipboard (TSV) and Download .tsv buttons +- Issues column flags validation problems per day row +- Summary strip with total hours and error counts + +### Public Hosting Security + +- All pages redirect to `/login` unless the `pulse_auth` cookie is valid. +- All API routes except `/api/authorize` and `/api/health` require the auth cookie. +- `PULSE_SECRET` is required and must be at least 32 characters. +- The auth cookie is `HttpOnly`, `SameSite=Lax`, `Secure` in production, and valid for 1 year. +- Login attempts and authenticated API calls are rate limited by IP. +- Mutating API requests require same-origin/CSRF protection. + +### Multi-Tenancy + +All data is scoped by `companyId`: + +- Employees belong to a company +- Time entries scoped to company + employee +- Audit logs scoped to company + +## Pages Reference + +| Path | Description | +| -------------------- | -------------------------------- | +| `/` | Landing page with company grid | +| `/login` | Secret-based login page | +| `/kiosk` | Kiosk company selector | +| `/kiosk/[companyId]` | Employee kiosk interface | +| `/admin` | Admin time grid dashboard | +| `/admin/manage` | Hotels and employees CRUD | +| `/admin/payroll` | Payroll export with TSV download | + +## API Reference + +| Endpoint | Method | Description | +| ---------------------------- | ------------------------ | ------------------------------ | +| `/api/companies` | GET, POST | Company CRUD | +| `/api/employees` | GET, POST | Employee CRUD | +| `/api/employees/with-status` | GET | Employee list with live status | +| `/api/punch` | POST | Kiosk punch (state machine) | +| `/api/time-entries` | GET, POST, PATCH, DELETE | Time entry CRUD + manual edits | +| `/api/payroll` | GET | Payroll TSV generation | +| `/api/authorize` | GET, POST | Cookie auth check/login | +| `/api/health` | GET | Unauthenticated healthcheck | + +### Authentication + +Open `/login` and enter `PULSE_SECRET`. Successful login sets the `pulse_auth` cookie. + +API calls from the browser use that cookie automatically. For non-browser scripts, first authenticate and store the cookie: + +```bash +curl -c cookies.txt \ + -H "Content-Type: application/json" \ + -H "x-pulse-csrf: 1" \ + -X POST https://localhost:3000/api/authorize \ + -d '{"secret":"your-32-character-minimum-secret"}' + +curl -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "x-pulse-csrf: 1" \ + -X PATCH https://localhost:3000/api/time-entries \ + -d '{"id":123,"timestamp":1747507200}' +``` + +## Database Schema + +```sql +companies (id, name, created_at) +employees (id, company_id, name, is_active, created_at) +time_entries (id, employee_id, company_id, type, timestamp, photo_base64, is_deleted) +audit_log (id, employee_id, company_id, admin_id, action, detail, created_at) +``` + +## Project Structure + +``` +pulse-clock/ +├── src/ +│ ├── app/ # Next.js App Router +│ │ ├── page.tsx # Landing page +│ │ ├── kiosk/ +│ │ │ └── [companyId]/ # Kiosk UI +│ │ ├── admin/ +│ │ │ ├── page.tsx # Time grid dashboard +│ │ │ ├── manage/page.tsx # Hotels & employees CRUD +│ │ │ └── payroll/page.tsx # Payroll export +│ │ └── api/ # API routes +│ ├── lib/ # Shared business logic +│ │ ├── api.ts # fetchApi wrapper +│ │ ├── validation.ts # State machine +│ │ ├── calculations.ts # Time calculations +│ │ ├── time.ts # Date utilities +│ │ └── time-entry-service.ts +│ ├── db/ +│ │ ├── schema.ts # Drizzle schema +│ │ └── index.ts # Drizzle client and migrations +│ └── components/ # React components +├── drizzle/ # SQL migrations +└── package.json +``` + +## Docker / Coolify Deployment + +The app includes a production `Dockerfile` for Coolify. + +Recommended Coolify settings: + +| Setting | Value | +| ------------------- | ------------ | +| Build Pack | Dockerfile | +| Dockerfile Location | `Dockerfile` | +| Port | `3000` | +| Persistent Storage | `/app/data` | + +Set these environment variables in Coolify: + +```bash +PULSE_SECRET=replace-with-at-least-32-random-characters +DATABASE_URL=/app/data/pulse-clock.db +PULSE_RATE_LIMIT=60 +PULSE_RATE_WINDOW=60 +``` + +Generate a deployment secret with: + +```bash +openssl rand -base64 32 +``` + +For local Docker testing: + +```bash +docker build -t pulse-clock . +docker run --rm -p 3000:3000 -v pulse-clock-data:/app/data --env-file .env pulse-clock +``` + +SQLite migrations run automatically on startup, and the database is stored in `/app/data` when deployed with the Docker defaults. +Run one app replica per SQLite volume so startup migrations are not executed concurrently. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..d63caaf --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DATABASE_URL ?? './data/pulse-clock.db', + }, +}); diff --git a/drizzle/0000_chubby_toro.sql b/drizzle/0000_chubby_toro.sql new file mode 100644 index 0000000..dcc39e4 --- /dev/null +++ b/drizzle/0000_chubby_toro.sql @@ -0,0 +1,44 @@ +CREATE TABLE `audit_log` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `employee_id` integer NOT NULL, + `company_id` integer NOT NULL, + `admin_id` integer DEFAULT 0 NOT NULL, + `action` text NOT NULL, + `detail` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `audit_employee_idx` ON `audit_log` (`employee_id`);--> statement-breakpoint +CREATE INDEX `audit_company_idx` ON `audit_log` (`company_id`);--> statement-breakpoint +CREATE TABLE `companies` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `employees` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `company_id` integer NOT NULL, + `name` text NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `company_active_idx` ON `employees` (`company_id`,`is_active`);--> statement-breakpoint +CREATE TABLE `time_entries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `employee_id` integer NOT NULL, + `company_id` integer NOT NULL, + `type` text NOT NULL, + `timestamp` integer DEFAULT (unixepoch()) NOT NULL, + `photo_base64` text, + `is_deleted` integer DEFAULT false NOT NULL, + FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `employee_timestamp_idx` ON `time_entries` (`employee_id`,`timestamp`);--> statement-breakpoint +CREATE INDEX `company_timestamp_idx` ON `time_entries` (`company_id`,`timestamp`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8fa1ad1 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,295 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a499d0ef-cc64-470d-98e6-79b06f766cb9", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "employee_id": { + "name": "employee_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "audit_employee_idx": { + "name": "audit_employee_idx", + "columns": ["employee_id"], + "isUnique": false + }, + "audit_company_idx": { + "name": "audit_company_idx", + "columns": ["company_id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_log_employee_id_employees_id_fk": { + "name": "audit_log_employee_id_employees_id_fk", + "tableFrom": "audit_log", + "tableTo": "employees", + "columnsFrom": ["employee_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_company_id_companies_id_fk": { + "name": "audit_log_company_id_companies_id_fk", + "tableFrom": "audit_log", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "companies": { + "name": "companies", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "employees": { + "name": "employees", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "company_active_idx": { + "name": "company_active_idx", + "columns": ["company_id", "is_active"], + "isUnique": false + } + }, + "foreignKeys": { + "employees_company_id_companies_id_fk": { + "name": "employees_company_id_companies_id_fk", + "tableFrom": "employees", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "time_entries": { + "name": "time_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "employee_id": { + "name": "employee_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "photo_base64": { + "name": "photo_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "employee_timestamp_idx": { + "name": "employee_timestamp_idx", + "columns": ["employee_id", "timestamp"], + "isUnique": false + }, + "company_timestamp_idx": { + "name": "company_timestamp_idx", + "columns": ["company_id", "timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "time_entries_employee_id_employees_id_fk": { + "name": "time_entries_employee_id_employees_id_fk", + "tableFrom": "time_entries", + "tableTo": "employees", + "columnsFrom": ["employee_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_company_id_companies_id_fk": { + "name": "time_entries_company_id_companies_id_fk", + "tableFrom": "time_entries", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..102570a --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1779035877611, + "tag": "0000_chubby_toro", + "breakpoints": true + } + ] +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f9c05ea --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,34 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + + // Ban raw fetch() for internal /api/ routes — prevents API envelope mismatch bugs + { + rules: { + 'react-hooks/set-state-in-effect': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.type='MemberExpression'][callee.object.name='fetch']", + message: + 'Use fetchApi() from @/lib/api-fetch instead of raw fetch(). Internal API routes return {data, success} envelope that must be unwrapped.', + }, + ], + }, + }, + + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + ]), +]); + +export default eslintConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..af2c632 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'same-origin' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'Permissions-Policy', value: 'camera=(self), microphone=()' }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..78e86ad --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5989 @@ +{ + "name": "pulse-clock", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-clock", + "version": "0.1.0", + "dependencies": { + "@libsql/client": "^0.17.3", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-webcam": "^7.2.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "daisyui": "^5.5.20", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "knip": "^6.14.1", + "prettier": "^3.8.3", + "react-doctor": "^0.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "dev": true, + "license": "ISC" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@libsql/client": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", + "integrity": "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.3", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.3.tgz", + "integrity": "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, + "node_modules/@next/env": { + "version": "16.2.6", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.130.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.130.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.65.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.65.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.40", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-install": { + "version": "0.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "yaml": "^2.8.3" + }, + "bin": { + "agent-install": "bin/agent-install.mjs" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/daisyui": { + "version": "5.5.20", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.20.tgz", + "integrity": "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.0", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/knip": { + "version": "6.14.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "minimist": "^1.2.8", + "oxc-parser": "^0.130.0", + "oxc-resolver": "^11.19.1", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oxc-parser": { + "version": "0.130.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.130.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.130.0", + "@oxc-parser/binding-android-arm64": "0.130.0", + "@oxc-parser/binding-darwin-arm64": "0.130.0", + "@oxc-parser/binding-darwin-x64": "0.130.0", + "@oxc-parser/binding-freebsd-x64": "0.130.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", + "@oxc-parser/binding-linux-arm64-musl": "0.130.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-musl": "0.130.0", + "@oxc-parser/binding-openharmony-arm64": "0.130.0", + "@oxc-parser/binding-wasm32-wasi": "0.130.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", + "@oxc-parser/binding-win32-x64-msvc": "0.130.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.130.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + } + }, + "node_modules/oxlint": { + "version": "1.65.0", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.65.0", + "@oxlint/binding-android-arm64": "1.65.0", + "@oxlint/binding-darwin-arm64": "1.65.0", + "@oxlint/binding-darwin-x64": "1.65.0", + "@oxlint/binding-freebsd-x64": "1.65.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", + "@oxlint/binding-linux-arm-musleabihf": "1.65.0", + "@oxlint/binding-linux-arm64-gnu": "1.65.0", + "@oxlint/binding-linux-arm64-musl": "1.65.0", + "@oxlint/binding-linux-ppc64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-musl": "1.65.0", + "@oxlint/binding-linux-s390x-gnu": "1.65.0", + "@oxlint/binding-linux-x64-gnu": "1.65.0", + "@oxlint/binding-linux-x64-musl": "1.65.0", + "@oxlint/binding-openharmony-arm64": "1.65.0", + "@oxlint/binding-win32-arm64-msvc": "1.65.0", + "@oxlint/binding-win32-ia32-msvc": "1.65.0", + "@oxlint/binding-win32-x64-msvc": "1.65.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/prompts": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-doctor": { + "version": "0.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-install": "0.0.5", + "commander": "^14.0.3", + "knip": "^6.10.0", + "ora": "^9.4.0", + "oxlint": "^1.63.0", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "typescript": ">=5.0.4 <7" + }, + "bin": { + "react-doctor": "bin/react-doctor.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "eslint-plugin-react-hooks": "^6 || ^7", + "eslint-plugin-react-you-might-not-need-an-effect": "^0.10" + }, + "peerDependenciesMeta": { + "eslint-plugin-react-hooks": { + "optional": true + }, + "eslint-plugin-react-you-might-not-need-an-effect": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT" + }, + "node_modules/react-webcam": { + "version": "7.2.0", + "license": "MIT", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/unbash": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e610bb --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "pulse-clock", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "PULSE_SKIP_DB_BOOTSTRAP=1 next build", + "start": "next start", + "lint": "eslint", + "test": "node scripts/test-calculations.mjs", + "typecheck": "tsc --noEmit", + "check:dead": "knip", + "check:react": "react-doctor . --yes --offline", + "format": "prettier --write .", + "check:format": "prettier --check .", + "check": "npm run lint && npm run typecheck && npm run check:dead && npm run check:react && npm run check:format" + }, + "dependencies": { + "@libsql/client": "^0.17.3", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-webcam": "^7.2.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "daisyui": "^5.5.20", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "knip": "^6.14.1", + "prettier": "^3.8.3", + "react-doctor": "^0.1.6", + "tailwindcss": "^4", + "typescript": "^5" + }, + "overrides": { + "postcss": "^8.5.14", + "esbuild": "^0.28.0" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/scripts/test-calculations.mjs b/scripts/test-calculations.mjs new file mode 100644 index 0000000..78f4410 --- /dev/null +++ b/scripts/test-calculations.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const source = readFileSync(new URL('../src/lib/calculations.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, +}).outputText; + +const sandbox = { + exports: {}, + module: { exports: {} }, + require, + Date, + Math, + Set, +}; +sandbox.exports = sandbox.module.exports; +vm.runInNewContext(compiled, sandbox, { filename: 'calculations.ts' }); + +const { calculateDayMinutes } = sandbox.module.exports; + +function unix(localDateTime) { + return Math.floor(new Date(localDateTime).getTime() / 1000); +} + +function entry(id, type, localDateTime) { + return { + id, + employeeId: 1, + type, + timestamp: unix(localDateTime), + isDeleted: false, + }; +} + +function minutes(entries, dateStr, options) { + return calculateDayMinutes(entries, dateStr, options); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T09:00:00'), entry(2, 'OUT', '2026-05-28T17:00:00')]; + assert.deepEqual(plain(minutes(entries, '2026-05-28')), { + clockMinutes: 480, + breakMinutes: 0, + workedMinutes: 480, + hasErrors: false, + errors: [], + }); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T22:00:00'), entry(2, 'OUT', '2026-05-29T06:00:00')]; + assert.equal(minutes(entries, '2026-05-28').workedMinutes, 120); + assert.equal(minutes(entries, '2026-05-29').workedMinutes, 360); + assert.equal(minutes(entries, '2026-05-28').hasErrors, false); + assert.equal(minutes(entries, '2026-05-29').hasErrors, false); +} + +{ + const entries = [ + entry(1, 'IN', '2026-05-28T22:00:00'), + entry(2, 'BREAK_OUT', '2026-05-28T23:30:00'), + entry(3, 'BREAK_IN', '2026-05-29T00:30:00'), + entry(4, 'OUT', '2026-05-29T06:00:00'), + ]; + assert.equal(minutes(entries, '2026-05-28').clockMinutes, 120); + assert.equal(minutes(entries, '2026-05-28').breakMinutes, 30); + assert.equal(minutes(entries, '2026-05-28').workedMinutes, 90); + assert.equal(minutes(entries, '2026-05-29').clockMinutes, 360); + assert.equal(minutes(entries, '2026-05-29').breakMinutes, 30); + assert.equal(minutes(entries, '2026-05-29').workedMinutes, 330); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T22:00:00')]; + const options = { nowUnix: unix('2026-05-29T02:00:00') }; + const firstDay = minutes(entries, '2026-05-28', options); + const secondDay = minutes(entries, '2026-05-29', options); + assert.equal(firstDay.workedMinutes, 120); + assert.equal(secondDay.workedMinutes, 120); + assert.deepEqual(plain(firstDay.errors), ['Still clocked in']); + assert.deepEqual(plain(secondDay.errors), ['Still clocked in']); +} + +console.log('calculation tests passed'); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..5cd8f6d --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Admin | Pulse Clock', + description: 'Review time cards, manage hotels and employees, and export payroll.', +}; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/admin/manage/page.tsx b/src/app/admin/manage/page.tsx new file mode 100644 index 0000000..e860db5 --- /dev/null +++ b/src/app/admin/manage/page.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Building2, Users, Pencil, Trash2, X, Check, Plus, ArrowLeft, Monitor } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; + +export default function ManagePage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [employees, setEmployees] = useState([]); + + const [editingCompany, setEditingCompany] = useState(null); + const [editingCompanyName, setEditingCompanyName] = useState(''); + const [editingEmployee, setEditingEmployee] = useState(null); + const [editingEmployeeName, setEditingEmployeeName] = useState(''); + + const [newCompanyName, setNewCompanyName] = useState(''); + const [showNewCompany, setShowNewCompany] = useState(false); + const [newEmployeeName, setNewEmployeeName] = useState(''); + const [showNewEmployee, setShowNewEmployee] = useState(false); + const [error, setError] = useState(null); + + const fail = (fallback: string, caught: unknown): never => { + setError(caught instanceof Error ? caught.message : fallback); + throw caught; + }; + + const loadCompanies = useCallback(async () => { + setCompanies(await fetchApi('/api/companies')); + }, []); + + const loadEmployees = useCallback(async (companyId: string) => { + setEmployees(await fetchApi(`/api/employees?companyId=${companyId}`)); + }, []); + + useEffect(() => { + loadCompanies(); + }, [loadCompanies]); + useEffect(() => { + if (selectedCompany) loadEmployees(selectedCompany); + else setEmployees([]); + }, [selectedCompany, loadEmployees]); + + const handleCreateCompany = async () => { + if (!newCompanyName.trim()) return; + try { + setError(null); + await fetchApi<{ company: Company }>('/api/companies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newCompanyName.trim() }), + }); + setNewCompanyName(''); + setShowNewCompany(false); + loadCompanies(); + } catch (caught) { + fail('Failed to create hotel', caught); + } + }; + + const handleUpdateCompany = async (id: number) => { + if (!editingCompanyName.trim()) return; + try { + setError(null); + await fetchApi<{ company: Company }>('/api/companies', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name: editingCompanyName.trim() }), + }); + setEditingCompany(null); + loadCompanies(); + } catch (caught) { + fail('Failed to update hotel', caught); + } + }; + + const handleDeleteCompany = async (id: number) => { + if (!confirm('Delete this hotel and all its employees and time entries?')) return; + try { + setError(null); + await fetchApi<{ company: Company }>(`/api/companies?id=${id}`, { method: 'DELETE' }); + if (selectedCompany === id.toString()) setSelectedCompany(''); + loadCompanies(); + } catch (caught) { + fail('Failed to delete hotel', caught); + } + }; + + const handleCreateEmployee = async () => { + if (!newEmployeeName.trim() || !selectedCompany) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ companyId: parseInt(selectedCompany), name: newEmployeeName.trim() }), + }); + setNewEmployeeName(''); + setShowNewEmployee(false); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to create employee', caught); + } + }; + + const handleUpdateEmployee = async (id: number) => { + if (!editingEmployeeName.trim()) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name: editingEmployeeName.trim() }), + }); + setEditingEmployee(null); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to update employee', caught); + } + }; + + const handleToggleActive = async (employee: Employee) => { + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: employee.id, isActive: !employee.isActive }), + }); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to update employee status', caught); + } + }; + + const handleDeleteEmployee = async (id: number) => { + if (!confirm('Delete this employee and all their time entries?')) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>(`/api/employees?id=${id}`, { method: 'DELETE' }); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to delete employee', caught); + } + }; + + return ( +
+
+
+
+ ⚙️ +

Manage Hotels & Employees

+
+
+ + + +
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Hotels Section */} +
+
+

+ Hotels +

+ +
+ + {showNewCompany && ( +
+
+
+ + setNewCompanyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateCompany()} + /> +
+ + +
+
+ )} + +
+ + + + + + + + + + {companies.map((c) => ( + + + + + + ))} + {companies.length === 0 && ( + + + + )} + +
NameIDActions
+ {editingCompany === c.id ? ( +
+ setEditingCompanyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleUpdateCompany(c.id)} + /> + + +
+ ) : ( + {c.name} + )} +
#{c.id} +
+ + + + + +
+
+ No hotels yet +
+
+
+ +
+ + {/* Employees Section */} +
+
+

+ Employees +

+
+ + +
+
+ + {showNewEmployee && selectedCompany && ( +
+
+
+ + setNewEmployeeName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateEmployee()} + /> +
+ + +
+
+ )} + +
+ + + + + + + + + + {employees.map((emp) => ( + + + + + + ))} + {!selectedCompany && ( + + + + )} + {selectedCompany && employees.length === 0 && ( + + + + )} + +
NameStatusActions
+ {editingEmployee === emp.id ? ( +
+ setEditingEmployeeName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleUpdateEmployee(emp.id)} + /> + + +
+ ) : ( + {emp.name} + )} +
+ + +
+ + +
+
+ Select a hotel to see employees +
+ No employees yet +
+
+
+
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..242bb85 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,865 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { addDays, subDays } from 'date-fns'; +import Image from 'next/image'; +import { + ChevronLeft, + ChevronRight, + Trash2, + Plus, + Building2, + Settings, + X, + Pencil, + Check, + Ban, + AlertTriangle, + Filter, + Download, + Clock, + Coffee, + Zap, +} from 'lucide-react'; +import Link from 'next/link'; +import { formatTime, formatDate, toUnix, dayRange, dateKey, fromInput } from '@/lib/time'; +import { calculateDayMinutes, formatHours } from '@/lib/calculations'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; +type TimeEntry = { + id: number; + employeeId: number; + companyId: number; + type: 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; + timestamp: number; + photoBase64: string | null; + isDeleted: boolean; + employeeName: string; +}; +type DayEntries = { + date: string; + entries: TimeEntry[]; + clockMinutes: number; + breakMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; +}; + +function Drawer({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) { + if (!open) return null; + return ( +
+ + {children} +
+
+ ); +} + +const DATE_PRESETS = [ + { label: 'Today', getDates: () => ({ start: new Date(), end: new Date() }) }, + { label: 'Yesterday', getDates: () => ({ start: subDays(new Date(), 1), end: subDays(new Date(), 1) }) }, + { label: 'Last 7 days', getDates: () => ({ start: subDays(new Date(), 6), end: new Date() }) }, + { label: 'Last 14 days', getDates: () => ({ start: subDays(new Date(), 13), end: new Date() }) }, + { label: 'Last 30 days', getDates: () => ({ start: subDays(new Date(), 29), end: new Date() }) }, +]; + +function dateInputValue(date: Date) { + return formatDate(toUnix(date), 'yyyy-MM-dd'); +} + +function dateInputToDate(value: string, boundary: 'start' | 'end' = 'start') { + return new Date(`${value}${boundary === 'start' ? 'T00:00:00' : 'T23:59:59'}`); +} + +export default function AdminPage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [employees, setEmployees] = useState([]); + const [allEntries, setAllEntries] = useState([]); + const [startDate, setStartDate] = useState(() => dateInputValue(subDays(new Date(), 13))); + const [endDate, setEndDate] = useState(() => dateInputValue(new Date())); + const [selectedDayEntries, setSelectedDayEntries] = useState<{ + employeeName: string; + entries: TimeEntry[]; + date: string; + errors: string[]; + } | null>(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [manualPunchForm, setManualPunchForm] = useState<{ + employeeId: number; + type: string; + timestamp: string; + forceOverride: boolean; + }>({ employeeId: 0, type: 'IN', timestamp: '', forceOverride: false }); + const [editingEntryIds, setEditingEntryIds] = useState>(new Set()); + const [editTimestamps, setEditTimestamps] = useState>({}); + const [editingOverride, setEditingOverride] = useState>({}); + const [showIssuesOnly, setShowIssuesOnly] = useState(false); + const [manualPunchError, setManualPunchError] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSheetOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + useEffect(() => { + fetchApi('/api/companies') + .then((data) => { + setCompanies(data); + setSelectedCompany((current) => current || data[0]?.id.toString() || ''); + }) + .catch(console.error); + }, []); + + useEffect(() => { + if (!selectedCompany) return; + fetchApi(`/api/employees?companyId=${selectedCompany}`).then(setEmployees).catch(console.error); + }, [selectedCompany]); + + const loadEntries = useCallback(async () => { + if (!selectedCompany) return; + const { start: startUnix } = dayRange(dateInputToDate(startDate)); + const { end: endUnix } = dayRange(dateInputToDate(endDate, 'end')); + const contextStartUnix = startUnix - 36 * 3600; + const contextEndUnix = endUnix + 36 * 3600; + fetchApi( + `/api/time-entries?companyId=${selectedCompany}&startDate=${contextStartUnix}&endDate=${contextEndUnix}`, + ) + .then(setAllEntries) + .catch(console.error); + }, [selectedCompany, startDate, endDate]); + + useEffect(() => { + loadEntries(); + }, [loadEntries]); + + const days = useMemo(() => { + const start = dateInputToDate(startDate); + const end = dateInputToDate(endDate); + const diff = Math.max((end.getTime() - start.getTime()) / 86400000, 0); + return Array.from({ length: diff + 1 }, (_, i) => addDays(start, i)); + }, [startDate, endDate]); + + const gridData = useMemo(() => { + const data: Record> = {}; + for (const emp of employees) { + const empData: Record = {}; + data[emp.id] = empData; + for (const day of days) { + const { start, end } = dayRange(day); + const dateStr = dateKey(toUnix(day)); + const empEntries = allEntries.filter((e) => e.employeeId === emp.id); + const dayEntries = empEntries.filter( + (e) => e.employeeId === emp.id && e.timestamp >= start && e.timestamp <= end, + ); + // Use the shared library , never duplicate inline + const result = calculateDayMinutes(empEntries, dateStr); + empData[dateStr] = { date: dateStr, entries: dayEntries, ...result }; + } + } + return data; + }, [employees, allEntries, days]); + + const employeeTotals = useMemo(() => { + const totals: Record< + number, + { clockMinutes: number; breakMinutes: number; workedMinutes: number; hasActivity: boolean } + > = {}; + for (const emp of employees) { + let clockMinutes = 0, + breakMinutes = 0, + workedMinutes = 0, + hasActivity = false; + for (const day of days) { + const dayData = gridData[emp.id]?.[dateKey(toUnix(day))]; + if (dayData) { + clockMinutes += dayData.clockMinutes; + breakMinutes += dayData.breakMinutes; + workedMinutes += dayData.workedMinutes; + if (dayData.entries.length > 0) hasActivity = true; + } + } + totals[emp.id] = { clockMinutes, breakMinutes, workedMinutes, hasActivity }; + } + return totals; + }, [employees, gridData, days]); + + const issueStats = useMemo(() => { + let totalErrors = 0; + const employeesWithErrors = new Set(); + for (const emp of employees) { + for (const day of days) { + if (gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors) { + totalErrors++; + employeesWithErrors.add(emp.id); + } + } + } + return { totalErrors, employeeCount: employeesWithErrors.size }; + }, [employees, days, gridData]); + + const visibleEmployees = useMemo(() => { + if (!showIssuesOnly) return employees; + return employees.filter((emp) => days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors)); + }, [employees, days, gridData, showIssuesOnly]); + + const openDetailDrawer = (employee: Employee, dateStr: string) => { + const dayData = gridData[employee.id]?.[dateStr]; + if (!dayData) return; + setSelectedDayEntries({ + employeeName: employee.name, + entries: dayData.entries, + date: dateStr, + errors: dayData.errors || [], + }); + setManualPunchForm({ + employeeId: employee.id, + type: 'IN', + timestamp: new Date().toISOString().slice(0, 16), + forceOverride: false, + }); + setEditingEntryIds(new Set()); + setEditTimestamps({}); + setEditingOverride({}); + setManualPunchError(null); + setSheetOpen(true); + }; + + const handleDeleteEntry = async (entryId: number) => { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, isDeleted: true }), + }); + loadEntries(); + }; + + const handleRestoreEntry = async (entryId: number) => { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, isDeleted: false }), + }); + loadEntries(); + }; + + const handleAddManualPunch = async () => { + if (!manualPunchForm || !selectedCompany) return; + setManualPunchError(null); + const timestamp = fromInput(manualPunchForm.timestamp); + try { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + employeeId: manualPunchForm.employeeId, + companyId: parseInt(selectedCompany), + type: manualPunchForm.type, + timestamp, + forceOverride: manualPunchForm.forceOverride, + }), + }); + setManualPunchForm((prev) => ({ + ...prev, + type: 'IN', + timestamp: new Date().toISOString().slice(0, 16), + forceOverride: false, + })); + loadEntries(); + } catch (e: unknown) { + setManualPunchError(e instanceof Error ? e.message : 'Failed to add punch'); + throw e; + } + }; + + const startEditingTimestamp = (entry: TimeEntry) => { + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.add(entry.id); + return n; + }); + setEditTimestamps((prev) => ({ + ...prev, + [entry.id]: `${formatDate(entry.timestamp, 'yyyy-MM-dd')}T${formatDate(entry.timestamp, 'HH:mm')}`, + })); + }; + + const saveTimestamp = async (entryId: number, forceOverride = false) => { + const ts = editTimestamps[entryId]; + if (!ts) return; + try { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, timestamp: fromInput(ts), forceOverride }), + }); + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.delete(entryId); + return n; + }); + setEditingOverride((prev) => { + const n = { ...prev }; + delete n[entryId]; + return n; + }); + loadEntries(); + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Failed to update timestamp'); + throw e; + } + }; + + const cancelEditing = (entryId: number) => { + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.delete(entryId); + return n; + }); + setEditingOverride((prev) => { + const n = { ...prev }; + delete n[entryId]; + return n; + }); + }; + + const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => { + const { start, end } = preset.getDates(); + setStartDate(dateInputValue(start)); + setEndDate(dateInputValue(end)); + }; + + const shiftWindow = (direction: 1 | -1) => { + // Shift by 1 day increments, preserving the existing date span + setStartDate(dateInputValue(addDays(dateInputToDate(startDate), direction))); + setEndDate(dateInputValue(addDays(dateInputToDate(endDate), direction))); + }; + + if (companies.length === 0) { + return ( +
+
+
📊
+

Admin Dashboard

+

No hotels yet.

+ + + +
+
+ ); + } + + const entryDotColors: Record = { + IN: 'bg-success', + OUT: 'bg-error', + BREAK_OUT: 'bg-warning', + BREAK_IN: 'bg-warning', + }; + const entryLabels: Record = { + IN: 'Clock In', + OUT: 'Clock Out', + BREAK_OUT: 'Break Start', + BREAK_IN: 'Break End', + }; + const entryIcons: Record = { IN: '🟢', OUT: '🔴', BREAK_OUT: '🟡', BREAK_IN: '🟡' }; + + return ( +
+ {/* Header */} +
+
+
+ 📊 +

Pulse Clock · Admin

+
+
+ + e.target.value && setStartDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> + + e.target.value && setEndDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+ + +
+ + + + + + + + +
+
+ {/* Date presets */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+
+ + {/* Issue Summary Banner , prominent */} +
+
+
+ {issueStats.totalErrors === 0 ? '✅' : } +
+
+ {issueStats.totalErrors === 0 ? ( + <> +
All Clear
+
+ {employees.length} employee{employees.length !== 1 ? 's' : ''} tracked · {days.length} day + {days.length !== 1 ? 's' : ''} · 0 issues found +
+ + ) : ( + <> +
+ ⚠️ {issueStats.totalErrors} Issue{issueStats.totalErrors !== 1 ? 's' : ''} Found +
+
+ Across {issueStats.employeeCount} employee{issueStats.employeeCount !== 1 ? 's' : ''} · Click any + flagged cell or{' '} + +
+ + )} +
+ {issueStats.totalErrors > 0 && ( + + )} +
+
+ + {/* Time Grid */} +
+
+ {/* Header row */} +
+
Employee
+ {days.map((day) => ( +
+
{formatDate(toUnix(day), 'EEE')}
+
{formatDate(toUnix(day), 'MM/dd')}
+
+ ))} +
+ Clock Hrs +
+
+ Break +
+
+ Net Hrs +
+
+ + {/* Employee rows */} + {visibleEmployees.map((emp) => { + const totals = employeeTotals[emp.id] || { + clockMinutes: 0, + breakMinutes: 0, + workedMinutes: 0, + hasActivity: false, + }; + const hasAnyError = days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors); + return ( +
+
+ {emp.name} + {hasAnyError && } +
+ {days.map((day) => { + const dateStr = dateKey(toUnix(day)); + const dayData = gridData[emp.id]?.[dateStr]; + const bMin = dayData?.breakMinutes || 0; + const wMin = dayData?.workedMinutes || 0; + const hasErr = dayData?.hasErrors || false; + const hasActivity = + (dayData?.entries?.length ?? 0) > 0 || + (dayData?.clockMinutes ?? 0) > 0 || + (dayData?.breakMinutes ?? 0) > 0; + return ( + + ); + })} +
+ {totals.hasActivity && formatHours(totals.clockMinutes) === '-' + ? '0m' + : formatHours(totals.clockMinutes)} +
+
+ {totals.hasActivity && formatHours(totals.breakMinutes) === '-' + ? '0m' + : formatHours(totals.breakMinutes)} +
+
+ {totals.hasActivity && formatHours(totals.workedMinutes) === '-' + ? '0m' + : formatHours(totals.workedMinutes)} +
+
+ ); + })} + + {visibleEmployees.length === 0 && ( +
+
👥
+

+ {showIssuesOnly + ? 'No issues found for any employee in this date range! 🎉' + : 'No employees found for this hotel. Add employees in the Manage page.'} +

+ {showIssuesOnly && ( + + )} +
+ )} +
+
+ + {/* Detail Drawer */} + setSheetOpen(false)}> +
+

{selectedDayEntries?.employeeName}

+ {selectedDayEntries?.date} +
+

+ {selectedDayEntries?.entries.filter((e) => !e.isDeleted).length + ? `${selectedDayEntries!.entries.filter((e) => !e.isDeleted).length} entries` + : 'No entries'}{' '} + for this day +

+ + {/* Summary strip */} + {selectedDayEntries && ( +
+
+
Clock Hours
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.clockMinutes || 0)} +
+
+
+
Break
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.breakMinutes || 0)} +
+
+
+
Net Worked
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.workedMinutes || 0)} +
+
+
+ )} + + {/* Issue summary section */} + {selectedDayEntries && selectedDayEntries.errors.length > 0 && ( +
+
+ + Issues Found +
+
    + {selectedDayEntries.errors.map((err) => ( +
  • + + {err} +
  • + ))} +
+
+ )} + + {/* Audit timeline */} +
+

Punch Timeline

+ {(selectedDayEntries?.entries ?? []) + .toSorted((a, b) => a.timestamp - b.timestamp) + .map((entry, i, arr) => { + const isEditing = editingEntryIds.has(entry.id); + const activeEntries = arr.filter((e) => !e.isDeleted); + const idx = activeEntries.indexOf(entry); + const forceOv = editingOverride[entry.id] || false; + return ( +
+ {!entry.isDeleted && ( +
+
+ {idx < activeEntries.length - 1 && idx >= 0 && ( +
+ )} +
+ )} +
+
+
+ {entryIcons[entry.type]} + + {entryLabels[entry.type] || entry.type} + + {isEditing ? ( +
+
+ setEditTimestamps((prev) => ({ ...prev, [entry.id]: e.target.value }))} + className="input input-bordered input-xs w-44" + /> + + +
+ +
+ ) : ( + <> + + {formatTime(entry.timestamp, 'MMM d, h:mm a')} + + + + )} +
+
+ {entry.isDeleted ? ( + + ) : ( + + )} +
+
+ {entry.photoBase64 && !entry.isDeleted && ( + Punch + )} +
+
+ ); + })} + {!selectedDayEntries?.entries?.length && ( +

No punches recorded for this day

+ )} +
+ +
+ + {/* Manual Punch Form */} +
+

+ Add Manual Punch +

+ + {/* Error display */} + {manualPunchError && ( +
+ {manualPunchError} +
+ )} + +
+
+ + +
+
+ + setManualPunchForm((prev) => (prev ? { ...prev, timestamp: e.target.value } : prev))} + /> +
+ + +
+
+ +
+ ); +} diff --git a/src/app/admin/payroll/page.tsx b/src/app/admin/payroll/page.tsx new file mode 100644 index 0000000..9c712c8 --- /dev/null +++ b/src/app/admin/payroll/page.tsx @@ -0,0 +1,466 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo, Fragment } from 'react'; +import { format, subDays, startOfMonth, endOfMonth, setDate } from 'date-fns'; +import { ArrowLeft, Copy, Check, Download, RefreshCw, AlertTriangle } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +import { formatHours } from '@/lib/calculations'; + +type Company = { id: number; name: string }; + +type PayrollDay = { + date: string; + inTime: string; + outTime: string; + breakMinutes: number; + clockMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; +}; + +type PayrollEmployee = { + id: number; + name: string; + days: PayrollDay[]; + totals: { clockMinutes: number; breakMinutes: number; workedMinutes: number }; +}; + +type PayrollData = { + company: string; + period: { startDate: string; endDate: string }; + employees: PayrollEmployee[]; + summary: string; +}; + +function fmtDecimalHours(mins: number): string { + if (mins === 0) return ','; + return (mins / 60).toFixed(2) + ' hrs'; +} + +// Date range presets specifically for payroll +const DATE_PRESETS = [ + { + label: 'Today', + apply: () => { + const today = format(new Date(), 'yyyy-MM-dd'); + return { start: today, end: today }; + }, + }, + { + label: 'Yesterday', + apply: () => { + const y = subDays(new Date(), 1); + const d = format(y, 'yyyy-MM-dd'); + return { start: d, end: d }; + }, + }, + { + label: 'Last 7 days', + apply: () => ({ + start: format(subDays(new Date(), 6), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, + { + label: 'Last 14 days', + apply: () => ({ + start: format(subDays(new Date(), 13), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, + { + label: 'Pay Period 1–15', + apply: () => { + const today = new Date(); + const day = today.getDate(); + if (day <= 15) { + return { + start: format(setDate(today, 1), 'yyyy-MM-dd'), + end: format(setDate(today, 15), 'yyyy-MM-dd'), + }; + } + return { + start: format(setDate(today, 16), 'yyyy-MM-dd'), + end: format(endOfMonth(today), 'yyyy-MM-dd'), + }; + }, + }, + { + label: 'Pay Period 16–31', + apply: () => { + const today = new Date(); + const day = today.getDate(); + if (day >= 16) { + return { + start: format(setDate(today, 16), 'yyyy-MM-dd'), + end: format(endOfMonth(today), 'yyyy-MM-dd'), + }; + } + return { + start: format(setDate(today, 1), 'yyyy-MM-dd'), + end: format(setDate(today, 15), 'yyyy-MM-dd'), + }; + }, + }, + { + label: 'This Month', + apply: () => ({ + start: format(startOfMonth(new Date()), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, +]; + +export default function PayrollPage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [startDate, setStartDate] = useState(() => format(subDays(new Date(), 13), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd')); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetchApi('/api/companies') + .then((data) => { + setCompanies(data); + setSelectedCompany((current) => current || data[0]?.id.toString() || ''); + }) + .catch(() => {}); + }, []); + + const loadPayroll = useCallback(async () => { + if (!selectedCompany || !startDate || !endDate) return; + setLoading(true); + setError(null); + try { + const result = await fetchApi( + `/api/payroll?companyId=${selectedCompany}&startDate=${startDate}&endDate=${endDate}`, + ); + setData(result); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load payroll data'); + setData(null); + throw e; + } finally { + setLoading(false); + } + }, [selectedCompany, startDate, endDate]); + useEffect(() => { + if (selectedCompany) loadPayroll(); + }, [selectedCompany, loadPayroll]); + + const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => { + const { start, end } = preset.apply(); + setStartDate(start); + setEndDate(end); + }; + + const totalStats = useMemo(() => { + if (!data) return { totalDays: 0, errorDays: 0, employeeCount: 0, totalHours: 0 }; + let errorDays = 0; + let totalHours = 0; + for (const emp of data.employees) { + totalHours += emp.totals.workedMinutes; + for (const day of emp.days) { + if (day.hasErrors) errorDays++; + } + } + return { + totalDays: data.employees.reduce((a, e) => a + e.days.filter((d) => d.inTime || d.outTime).length, 0), + errorDays, + employeeCount: data.employees.length, + totalHours, + }; + }, [data]); + + const copyTSV = async () => { + if (!data?.summary) return; + try { + await navigator.clipboard.writeText(data.summary); + } catch (caught) { + const ta = document.createElement('textarea'); + ta.value = data.summary; + document.body.appendChild(ta); + ta.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(ta); + if (!copied) throw caught; + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const downloadTSV = () => { + if (!data?.summary) return; + const blob = new Blob([data.summary], { type: 'text/tab-separated-values' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `payroll-${data.company}-${data.period.startDate}-to-${data.period.endDate}.tsv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+
+ 💰 +

Payroll Export

+
+ + + +
+
+ +
+ {/* Controls */} +
+
+ {/* Hotel */} +
+ + +
+ + {/* Date range */} +
+ + setStartDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+
+ + setEndDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+ + {/* Date presets */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+ + {/* Generate button */} + +
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* Summary + Export actions */} + {data && ( + <> + {/* Summary strip */} +
+
+
+
+
Hotel
+
{data.company}
+
+
+
Period
+
+ {data.period.startDate} → {data.period.endDate} +
+
+
+
Employees
+
{data.employees.length}
+
+
+
Total Days
+
{totalStats.totalDays}
+
+
+
Total Hours
+
{formatHours(totalStats.totalHours)}
+
+ {totalStats.errorDays > 0 && ( +
+
⚠️ Issues
+
+ {totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with errors +
+
+ )} +
+ + {/* Export actions */} +
+ + +
+
+ + {/* Issue warning bar */} + {totalStats.errorDays > 0 && ( +
+ + + {totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with validation issues , review + the Notes column below before exporting to payroll. Errors + include: missing clock-out, unended breaks, or consecutive duplicate entries. + +
+ )} +
+ + {/* Preview Table */} + {data.employees.length > 0 ? ( +
+
+

Payroll Preview

+

+ TSV columns: Employee Name | Date | Clock In | Clock Out | Break (min) | Net Hours | Notes +

+
+
+ + + + + + + + + + + + + + {data.employees.map((emp) => { + const hasAnyError = emp.days.some((d) => d.hasErrors); + const visibleDays = emp.days.filter( + (d) => + d.inTime || d.outTime || d.clockMinutes > 0 || d.workedMinutes > 0 || d.breakMinutes > 0, + ); + return ( + + {visibleDays.map((day, di) => ( + + {di === 0 && ( + + )} + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + ); + })} + +
EmployeeDateInOutBreak (min)Net HoursNotes / Issues
+
+ {emp.name} + {hasAnyError && } +
+
{day.date}{day.inTime || ','}{day.outTime || ','}{day.breakMinutes > 0 ? day.breakMinutes : ','} 0 ? 'text-success' : 'text-base-content/30'}`} + > + {day.workedMinutes > 0 ? fmtDecimalHours(day.workedMinutes) : ','} + + {day.hasErrors ? ( +
+ {day.errors.map((err, ei) => ( + + ⚠️ {err} + + ))} +
+ ) : day.workedMinutes > 0 ? ( + ✅ OK + ) : ( + , + )} +
+ {emp.name} , TOTAL + ,,{formatHours(emp.totals.breakMinutes)}{fmtDecimalHours(emp.totals.workedMinutes)} +
+
+
+ ) : ( +
+
📋
+

No time entries found for this period

+

Try a different date range

+
+ )} + + )} + + {!data && !loading && ( +
+
💰
+

Select a hotel and date range, then click Preview

+
+ )} +
+
+ ); +} diff --git a/src/app/api/authorize/route.ts b/src/app/api/authorize/route.ts new file mode 100644 index 0000000..6e02ada --- /dev/null +++ b/src/app/api/authorize/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPulseSecret, verifyCookie, setAuthCookie, verifySecret } from '@/lib/auth-cookie'; + +export async function GET(request: NextRequest) { + if (verifyCookie(request)) { + return NextResponse.json({ ok: true }); + } + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { secret } = body as { secret?: string }; + + if (!secret) { + return NextResponse.json({ error: 'Secret is required' }, { status: 400 }); + } + + if (!getPulseSecret()) { + return NextResponse.json( + { error: 'Server not configured: PULSE_SECRET must be at least 32 characters' }, + { status: 500 }, + ); + } + + if (!verifySecret(secret)) { + return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }); + } + + const response = NextResponse.json({ ok: true }); + setAuthCookie(response); + return response; + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} diff --git a/src/app/api/companies/route.ts b/src/app/api/companies/route.ts new file mode 100644 index 0000000..b1c52bc --- /dev/null +++ b/src/app/api/companies/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { companies, employees, timeEntries, auditLog } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { CompanySchema, CompanyUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; + +export async function GET() { + await dbReady; + const results = await db.select().from(companies).all(); + return NextResponse.json(ok(results)); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = CompanySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db.insert(companies).values(parsed.data).returning().get(); + return NextResponse.json(ok({ company })); +} + +export async function PATCH(request: NextRequest) { + const body = await request.json(); + const parsed = CompanyUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db + .update(companies) + .set({ name: parsed.data.name }) + .where(eq(companies.id, parsed.data.id)) + .returning() + .get(); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + return NextResponse.json(ok({ company })); +} + +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const parsed = id ? parseInt(id) : NaN; + if (isNaN(parsed)) { + return NextResponse.json(err('Invalid id'), { status: 400 }); + } + + await dbReady; + const deletedCompany = await db.transaction(async (tx) => { + await Promise.all([ + tx.delete(auditLog).where(eq(auditLog.companyId, parsed)).run(), + tx.delete(timeEntries).where(eq(timeEntries.companyId, parsed)).run(), + ]); + await tx.delete(employees).where(eq(employees.companyId, parsed)).run(); + return tx.delete(companies).where(eq(companies.id, parsed)).returning().get(); + }); + + if (!deletedCompany) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + return NextResponse.json(ok({ company: deletedCompany })); +} diff --git a/src/app/api/employees/route.ts b/src/app/api/employees/route.ts new file mode 100644 index 0000000..7a6bf3b --- /dev/null +++ b/src/app/api/employees/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, companies, timeEntries } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { EmployeeSchema, EmployeeUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = searchParams.get('companyId'); + if (!companyId) { + return NextResponse.json(err('companyId is required'), { status: 400 }); + } + await dbReady; + const result = await db + .select({ + id: employees.id, + name: employees.name, + isActive: employees.isActive, + companyId: employees.companyId, + }) + .from(employees) + .where(and(eq(employees.companyId, parseInt(companyId)), eq(employees.isActive, true))) + .all(); + return NextResponse.json(ok(result)); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = EmployeeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db.select().from(companies).where(eq(companies.id, parsed.data.companyId)).get(); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + const employee = await db.insert(employees).values(parsed.data).returning().get(); + return NextResponse.json(ok({ employee })); +} + +export async function PATCH(request: NextRequest) { + const body = await request.json(); + const parsed = EmployeeUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { id, ...rest } = parsed.data; + const updateData: Partial = {}; + if (rest.name !== undefined) updateData.name = rest.name; + if (rest.isActive !== undefined) updateData.isActive = rest.isActive; + if (Object.keys(updateData).length === 0) { + return NextResponse.json(err('No fields to update'), { status: 400 }); + } + await dbReady; + const employee = await db.update(employees).set(updateData).where(eq(employees.id, id)).returning().get(); + if (!employee) { + return NextResponse.json(err('Employee not found'), { status: 404 }); + } + return NextResponse.json(ok({ employee })); +} + +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json(err('id is required'), { status: 400 }); + } + const parsedId = parseInt(id); + if (isNaN(parsedId)) { + return NextResponse.json(err('Invalid id'), { status: 400 }); + } + await dbReady; + const deletedEmployee = await db.transaction(async (tx) => { + await tx.delete(timeEntries).where(eq(timeEntries.employeeId, parsedId)).run(); + return tx.delete(employees).where(eq(employees.id, parsedId)).returning().get(); + }); + if (!deletedEmployee) { + return NextResponse.json(err('Employee not found'), { status: 404 }); + } + return NextResponse.json(ok({ employee: deletedEmployee })); +} diff --git a/src/app/api/employees/with-status/route.ts b/src/app/api/employees/with-status/route.ts new file mode 100644 index 0000000..3fb5fd8 --- /dev/null +++ b/src/app/api/employees/with-status/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, timeEntries } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { statusAfter } from '@/lib/validation'; +import { ok, err } from '@/lib/api-response'; +import { calculateLiveShiftElapsed } from '@/lib/calculations'; +import { parseId } from '@/lib/params'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = parseId(searchParams.get('companyId')); + if (companyId == null) { + return NextResponse.json(err('valid companyId is required'), { status: 400 }); + } + + await dbReady; + const activeEmps = await db + .select({ id: employees.id, name: employees.name }) + .from(employees) + .where(and(eq(employees.companyId, companyId), eq(employees.isActive, true))) + .all(); + + if (activeEmps.length === 0) { + return NextResponse.json(ok({ employees: [], fetchedAt: Math.floor(Date.now() / 1000) })); + } + + const allEntries = await db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.companyId, companyId), + eq(timeEntries.isDeleted, false), + inArray( + timeEntries.employeeId, + activeEmps.map((emp) => emp.id), + ), + ), + ) + .orderBy(timeEntries.employeeId, timeEntries.timestamp, timeEntries.id) + .all(); + + const employeeList = activeEmps.map((emp) => { + const entries = allEntries.filter((entry) => entry.employeeId === emp.id); + const latestEntry = entries.at(-1); + return { + id: emp.id, + name: emp.name, + status: latestEntry ? statusAfter(latestEntry.type) : 'OUT', + lastPunchTimestamp: latestEntry?.timestamp ?? null, + ...calculateLiveShiftElapsed(entries), + }; + }); + + return NextResponse.json(ok({ employees: employeeList, fetchedAt: Math.floor(Date.now() / 1000) })); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..3efea1c --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/payroll/route.ts b/src/app/api/payroll/route.ts new file mode 100644 index 0000000..dd24c66 --- /dev/null +++ b/src/app/api/payroll/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, timeEntries, companies } from '@/db/schema'; +import { eq, and, gte, lte } from 'drizzle-orm'; +import { calculateDayMinutes, formatHours } from '@/lib/calculations'; +import { format, addDays } from 'date-fns'; +import { ok, err } from '@/lib/api-response'; +import { parseId } from '@/lib/params'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = parseId(searchParams.get('companyId')); + const startDateStr = searchParams.get('startDate'); + const endDateStr = searchParams.get('endDate'); + + if (companyId == null) { + return NextResponse.json(err('valid companyId is required'), { status: 400 }); + } + + if (!startDateStr || !endDateStr) { + return NextResponse.json(err('startDate and endDate are required'), { status: 400 }); + } + + const startDate = new Date(startDateStr + 'T00:00:00'); + const endDate = new Date(endDateStr + 'T23:59:59'); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return NextResponse.json(err('Invalid date format'), { status: 400 }); + } + const startUnix = Math.floor(startDate.getTime() / 1000); + const endUnix = Math.floor(endDate.getTime() / 1000); + const contextStartUnix = startUnix - 36 * 3600; + const contextEndUnix = endUnix + 36 * 3600; + + await dbReady; + const [company, companyEmployees, allEntries] = await Promise.all([ + db.select().from(companies).where(eq(companies.id, companyId)).get(), + db + .select() + .from(employees) + .where(and(eq(employees.companyId, companyId), eq(employees.isActive, true))) + .all(), + db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.companyId, companyId), + eq(timeEntries.isDeleted, false), + gte(timeEntries.timestamp, contextStartUnix), + lte(timeEntries.timestamp, contextEndUnix), + ), + ) + .orderBy(timeEntries.timestamp) + .all(), + ]); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + + const days: string[] = []; + let dayDate = startDate; + while (dayDate <= endDate) { + days.push(format(dayDate, 'yyyy-MM-dd')); + dayDate = addDays(dayDate, 1); + } + + const resultEmployees = companyEmployees.map((emp) => { + const empEntries = allEntries.filter((e) => e.employeeId === emp.id); + const empDays = days.map((dayStr) => { + const dayStart = new Date(dayStr + 'T00:00:00').getTime() / 1000; + const dayEnd = new Date(dayStr + 'T23:59:59').getTime() / 1000; + const dayEntries = empEntries.filter((e) => e.timestamp >= dayStart && e.timestamp <= dayEnd); + // Single call , errors are returned directly + const { clockMinutes, breakMinutes, workedMinutes, hasErrors, errors } = calculateDayMinutes(empEntries, dayStr); + + const inEntry = dayEntries.find((e) => e.type === 'IN' && !e.isDeleted); + const outEntry = dayEntries.find((e) => e.type === 'OUT' && !e.isDeleted); + + return { + date: dayStr, + inTime: inEntry ? format(new Date(inEntry.timestamp * 1000), 'h:mm a') : '', + outTime: outEntry ? format(new Date(outEntry.timestamp * 1000), 'h:mm a') : '', + breakMinutes: Math.round(breakMinutes), + clockMinutes: Math.round(clockMinutes), + workedMinutes: Math.round(workedMinutes), + hasErrors, + errors, + }; + }); + + const totals = empDays.reduce( + (acc, day) => ({ + clockMinutes: acc.clockMinutes + day.clockMinutes, + breakMinutes: acc.breakMinutes + day.breakMinutes, + workedMinutes: acc.workedMinutes + day.workedMinutes, + }), + { clockMinutes: 0, breakMinutes: 0, workedMinutes: 0 }, + ); + + return { + id: emp.id, + name: emp.name, + days: empDays, + totals, + }; + }); + + let summary = 'Employee\tDate\tIn\tOut\tBreak\tNet\tNotes\n'; + for (const emp of resultEmployees) { + for (const day of emp.days) { + const hasErrors = day.errors.length > 0; + summary += `${emp.name}\t${day.date}\t${day.inTime}\t${day.outTime}\t${formatHours(day.breakMinutes)}\t${formatHours(day.workedMinutes)}\t${hasErrors ? '[WARN] ' + day.errors.join('; ') : ''}\n`; + } + summary += `${emp.name}\tTOTAL\t\t\t${formatHours(emp.totals.breakMinutes)}\t${formatHours(emp.totals.workedMinutes)}\t\n`; + } + + return NextResponse.json( + ok({ + company: company.name, + period: { startDate: startDateStr, endDate: endDateStr }, + employees: resultEmployees, + summary, + }), + ); +} diff --git a/src/app/api/punch/route.ts b/src/app/api/punch/route.ts new file mode 100644 index 0000000..9fcd211 --- /dev/null +++ b/src/app/api/punch/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PunchSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; +import { createValidatedTimeEntry } from '@/lib/time-entry-service'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = PunchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { employeeId, companyId, type, photoBase64 } = parsed.data; + + const result = await createValidatedTimeEntry({ + employeeId, + companyId, + type, + photoBase64, + source: 'kiosk', + }); + + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json( + ok({ + entry: { + id: result.entry.id, + employeeId: result.entry.employeeId, + type: result.entry.type, + timestamp: result.entry.timestamp, + }, + status: result.status, + }), + ); + } catch (error) { + console.error('Punch error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} diff --git a/src/app/api/time-entries/route.ts b/src/app/api/time-entries/route.ts new file mode 100644 index 0000000..a44f9fa --- /dev/null +++ b/src/app/api/time-entries/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { timeEntries, employees } from '@/db/schema'; +import { eq, and, gte, lte } from 'drizzle-orm'; +import { validateTimestampChronology } from '@/lib/validation'; +import { ManualEntrySchema, TimeEntryUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; +import { createValidatedTimeEntry } from '@/lib/time-entry-service'; + +function parseRangeParam(value: string, boundary: 'start' | 'end'): number { + if (/^\d+$/.test(value)) return Number(value); + + const suffix = boundary === 'start' ? 'T00:00:00' : 'T23:59:59'; + return Math.floor(new Date(`${value}${suffix}`).getTime() / 1000); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = searchParams.get('companyId'); + const employeeId = searchParams.get('employeeId'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!companyId) { + return NextResponse.json(err('companyId is required'), { status: 400 }); + } + + const whereConditions = [eq(timeEntries.companyId, parseInt(companyId))]; + if (employeeId) whereConditions.push(eq(timeEntries.employeeId, parseInt(employeeId))); + if (startDate) whereConditions.push(gte(timeEntries.timestamp, parseRangeParam(startDate, 'start'))); + if (endDate) whereConditions.push(lte(timeEntries.timestamp, parseRangeParam(endDate, 'end'))); + + await dbReady; + const results = await db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + companyId: timeEntries.companyId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + photoBase64: timeEntries.photoBase64, + isDeleted: timeEntries.isDeleted, + employeeName: employees.name, + }) + .from(timeEntries) + .innerJoin(employees, eq(timeEntries.employeeId, employees.id)) + .where(and(...whereConditions)) + .orderBy(timeEntries.timestamp) + .all(); + + return NextResponse.json(ok(results)); +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const parsed = TimeEntryUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { id, isDeleted, timestamp, forceOverride } = parsed.data; + + await dbReady; + const result = await db.transaction(async (tx) => { + const existing = await tx.select().from(timeEntries).where(eq(timeEntries.id, id)).get(); + if (!existing) { + return { ok: false as const, status: 404, error: 'Entry not found' }; + } + + if (timestamp !== undefined && timestamp !== existing.timestamp) { + const entries = await tx + .select() + .from(timeEntries) + .where( + and( + eq(timeEntries.employeeId, existing.employeeId), + eq(timeEntries.companyId, existing.companyId), + eq(timeEntries.isDeleted, false), + ), + ) + .all(); + const validation = validateTimestampChronology(entries, id, timestamp, !!forceOverride); + if (!validation.valid) { + return { ok: false as const, status: 400, error: validation.error ?? 'Validation failed' }; + } + } + + const updateData: Partial = {}; + if (isDeleted !== undefined) updateData.isDeleted = isDeleted; + if (timestamp !== undefined) updateData.timestamp = timestamp; + + const updated = await tx.update(timeEntries).set(updateData).where(eq(timeEntries.id, id)).returning().get(); + + return { ok: true as const, entry: updated }; + }); + + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json(ok({ entry: result.entry })); + } catch (error) { + console.error('Update time entry error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = ManualEntrySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { employeeId, companyId, type, timestamp, photoBase64, forceOverride } = parsed.data; + + const result = await createValidatedTimeEntry({ + employeeId, + companyId, + type, + timestamp, + photoBase64: photoBase64 || null, + forceOverride: !!forceOverride, + source: 'manual', + }); + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json(ok({ entry: result.entry, overridden: result.overridden })); + } catch (error) { + console.error('Add manual entry error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..ee95d2c --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import 'daisyui/daisyui.css'; diff --git a/src/app/kiosk/[companyId]/page.tsx b/src/app/kiosk/[companyId]/page.tsx new file mode 100644 index 0000000..6e8fdf9 --- /dev/null +++ b/src/app/kiosk/[companyId]/page.tsx @@ -0,0 +1,390 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Webcam from 'react-webcam'; +import { X, Loader2, Home, RefreshCw } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; +type PunchType = 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; +type EmployeeWithStatus = { + id: number; + name: string; + status: 'IN' | 'OUT' | 'BREAK'; + lastPunchTimestamp: number | null; + shiftStartTimestamp: number | null; + activeBreakStartTimestamp: number | null; + completedBreakSeconds: number; +}; +type PunchResponse = { status: string; entry: { type: string } }; + +function useNowTime() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + return now; +} + +export default function KioskPage() { + const params = useParams(); + const companyId = params.companyId as string; + + const [company, setCompany] = useState(null); + const [employees, setEmployees] = useState([]); + const [punchModal, setPunchModal] = useState(null); + const [showSuccess, setShowSuccess] = useState(null); + const [isPunching, setIsPunching] = useState(false); + const [punchingType, setPunchingType] = useState(null); + const [cameraError, setCameraError] = useState(false); + const [punchError, setPunchError] = useState(null); + const webcamRef = useRef(null); + const now = useNowTime(); + + useEffect(() => { + if (!companyId) return; + fetchApi('/api/companies') + .then((data) => { + const found = data.find((c) => c.id.toString() === companyId); + if (found) setCompany(found); + }) + .catch(() => {}); + }, [companyId]); + + const loadEmployees = useCallback(async () => { + if (!companyId) return; + try { + const data = await fetchApi<{ employees: EmployeeWithStatus[] }>( + `/api/employees/with-status?companyId=${companyId}`, + ); + setEmployees(data.employees); + } catch { + setEmployees([]); + } + }, [companyId]); + + // Simple polling keeps kiosk tablets in sync without a long-lived stream. + useEffect(() => { + if (!companyId) return; + loadEmployees(); + const interval = setInterval(loadEmployees, 5000); + return () => { + clearInterval(interval); + }; + }, [companyId, loadEmployees]); + + useEffect(() => { + if (!punchModal) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPunching) setPunchModal(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [punchModal, isPunching]); + + const openPunchModal = (employee: Employee) => { + setPunchModal(employee); + setPunchingType(null); + setPunchError(null); + }; + + const handleAction = async (type: PunchType) => { + if (!punchModal || isPunching) return; + setPunchingType(type); + setIsPunching(true); + setPunchError(null); + try { + const photo = webcamRef.current?.getScreenshot() || null; + const data = await fetchApi('/api/punch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + employeeId: punchModal.id, + companyId: parseInt(companyId), + type, + photoBase64: photo || undefined, + }), + }); + const labels: Record = { + IN: 'Clocked In', + OUT: 'Clocked Out', + BREAK_IN: 'Break Ended', + BREAK_OUT: 'Break Started', + }; + setShowSuccess(`${punchModal.name} , ${labels[data.entry.type] || data.entry.type}`); + setPunchModal(null); + loadEmployees(); + setTimeout(() => setShowSuccess(null), 2500); + } catch (err) { + console.error('Punch failed:', err); + setPunchError(err instanceof Error ? err.message : 'Connection error , please try again'); + } finally { + setIsPunching(false); + } + }; + + const statusColor = (status?: 'IN' | 'OUT' | 'BREAK') => { + switch (status) { + case 'IN': + return 'border-success bg-success/10 text-success'; + case 'BREAK': + return 'border-warning bg-warning/10 text-warning'; + default: + return 'border-base-300 bg-base-200 text-base-content/60'; + } + }; + + const statusLabel = (status?: 'IN' | 'OUT' | 'BREAK') => { + switch (status) { + case 'IN': + return 'On Shift'; + case 'BREAK': + return 'On Break'; + default: + return 'Off Duty'; + } + }; + + const refreshKiosk = () => { + window.location.reload(); + }; + + return ( +
+ {showSuccess && ( +
+
+ + {showSuccess} +
+
+ )} + +
+
+ +

Pulse Clock

+ {company && {company.name}} +
+
+ + {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + + + + +
+
+ +
+ {employees.length === 0 ? ( +
+
👥
+

No active employees found

+
+ ) : ( +
+ {employees.map((emp) => ( + + openPunchModal({ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) }) + } + statusColor={statusColor} + statusLabel={statusLabel} + /> + ))} +
+ )} +
+ + {punchModal && ( +
+ + +

{punchModal.name}

+

+ Status:{' '} + + {statusLabel(currentModalStatus(employees, punchModal.id))} + +

+ + {punchError && ( +
+
+ +
+
Punch Rejected
+
{punchError}
+
+ +
+
+ )} + + {isPunching ? ( +
+ +

+ {punchingType === 'OUT' + ? 'Clocking out...' + : punchingType === 'BREAK_OUT' + ? 'Starting break…' + : punchingType === 'BREAK_IN' + ? 'Ending break…' + : 'Clocking in…'} +

+
+ ) : ( +
+
+ {cameraError ? ( +
+ Camera unavailable +
+ ) : ( + setCameraError(true)} + className="w-full scale-x-[-1]" + /> + )} +
+ +
+ {currentModalStatus(employees, punchModal.id) !== 'IN' && + currentModalStatus(employees, punchModal.id) !== 'BREAK' && ( + + )} + {(currentModalStatus(employees, punchModal.id) === 'IN' || + currentModalStatus(employees, punchModal.id) === 'BREAK') && ( + + )} + {currentModalStatus(employees, punchModal.id) === 'IN' && ( + + )} + {currentModalStatus(employees, punchModal.id) === 'BREAK' && ( + + )} +
+
+ )} +
+
+ )} +
+ ); +} + +function currentModalStatus(employees: EmployeeWithStatus[], employeeId: number): EmployeeWithStatus['status'] { + return employees.find((employee) => employee.id === employeeId)?.status ?? 'OUT'; +} + +// ── Sub-component so hooks are called at the top level (Rules of Hooks) ───────── + +type EmployeeCardProps = { + employee: Employee; + status?: 'IN' | 'OUT' | 'BREAK'; + shiftStartUnix: number | null; + activeBreakStartUnix: number | null; + completedBreakSeconds: number; + onClick: () => void; + statusColor: (status?: 'IN' | 'OUT' | 'BREAK') => string; + statusLabel: (status?: 'IN' | 'OUT' | 'BREAK') => string; +}; + +function EmployeeCard({ + employee, + status, + shiftStartUnix, + activeBreakStartUnix, + completedBreakSeconds, + onClick, + statusColor, + statusLabel, +}: EmployeeCardProps) { + const elapsed = shiftStartUnix + ? formatNetShiftElapsed(shiftStartUnix, completedBreakSeconds, activeBreakStartUnix) + : ''; + + return ( + + ); +} + +function formatNetShiftElapsed( + shiftStartUnix: number, + completedBreakSeconds: number, + activeBreakStartUnix: number | null, +): string { + const nowUnix = Math.floor(Date.now() / 1000); + const activeBreakSeconds = activeBreakStartUnix ? Math.max(nowUnix - activeBreakStartUnix, 0) : 0; + const secs = Math.max(nowUnix - shiftStartUnix - completedBreakSeconds - activeBreakSeconds, 0); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} diff --git a/src/app/kiosk/layout.tsx b/src/app/kiosk/layout.tsx new file mode 100644 index 0000000..a5c63a9 --- /dev/null +++ b/src/app/kiosk/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata, Viewport } from 'next'; +import { KioskZoomLock } from '@/components/kiosk-zoom-lock'; + +export const metadata: Metadata = { + title: 'Kiosk | Pulse Clock', + description: 'Employee kiosk for clocking in, clocking out, and breaks.', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default function KioskLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/kiosk/page.tsx b/src/app/kiosk/page.tsx new file mode 100644 index 0000000..8c416ce --- /dev/null +++ b/src/app/kiosk/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function KioskIndex() { + redirect('/admin/manage'); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..bd9511a --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { AuthGate } from '@/components/auth-gate'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Pulse Clock', + description: 'Multi-tenant hotel time clock system', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..6e89743 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Suspense, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [secret, setSecret] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const nextPath = useMemo(() => { + const requested = searchParams.get('next') ?? '/'; + return requested.startsWith('/') && !requested.startsWith('//') ? requested : '/'; + }, [searchParams]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!secret.trim()) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-pulse-csrf': '1' }, + body: JSON.stringify({ secret }), + }); + + if (!response.ok) { + setError(response.status === 500 ? 'Server auth is not configured' : 'Invalid secret'); + setLoading(false); + return; + } + + router.replace(nextPath); + router.refresh(); + } catch { + setError('Connection error'); + setLoading(false); + } + } + + return ( +
+
+
+

Pulse Clock

+

Enter the admin secret to continue.

+
+ +
+ {error && ( +
+ {error} +
+ )} + + setSecret(event.target.value)} + autoFocus + disabled={loading} + autoComplete="current-password" + /> + + +
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + + } + > + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..d516359 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/admin'); +} diff --git a/src/components/auth-gate.tsx b/src/components/auth-gate.tsx new file mode 100644 index 0000000..1e75612 --- /dev/null +++ b/src/components/auth-gate.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; + +function AuthGateInner({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [showModal, setShowModal] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [checked, setChecked] = useState(false); + + async function checkAuth() { + try { + const res = await fetch('/api/authorize'); + if (res.ok) { + setChecked(true); + } else { + setShowModal(true); + setChecked(true); + } + } catch { + setShowModal(true); + setChecked(true); + } + } + + useEffect(() => { + if (pathname === '/login') { + setChecked(true); + return; + } + + if (searchParams.get('unauthorized') === '1') { + setShowModal(true); + setChecked(true); + } else { + checkAuth(); + } + }, [pathname, searchParams]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!password.trim()) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-pulse-csrf': '1' }, + body: JSON.stringify({ secret: password }), + }); + + if (res.ok) { + window.location.reload(); + } else { + setError('Invalid secret'); + setLoading(false); + } + } catch { + setError('Connection error'); + setLoading(false); + } + } + + if (!checked) { + return ( +
+ +
+ ); + } + + return ( + <> + {children} + {showModal && ( +
+
+
+
🔒
+

Authorization Required

+

+ Enter the admin secret to continue +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + setPassword(e.target.value)} + autoFocus + disabled={loading} + /> + + +
+
+
+ )} + + ); +} + +export function AuthGate({ children }: { children: React.ReactNode }) { + return ( + + +
+ } + > + {children} + + ); +} diff --git a/src/components/kiosk-zoom-lock.tsx b/src/components/kiosk-zoom-lock.tsx new file mode 100644 index 0000000..3af0542 --- /dev/null +++ b/src/components/kiosk-zoom-lock.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; + +export function KioskZoomLock() { + useEffect(() => { + const preventMultiTouchZoom = (event: TouchEvent) => { + if (event.touches.length > 1) event.preventDefault(); + }; + + const preventGestureZoom = (event: Event) => { + event.preventDefault(); + }; + + const preventCtrlWheelZoom = (event: WheelEvent) => { + if (event.ctrlKey) event.preventDefault(); + }; + + document.addEventListener('touchmove', preventMultiTouchZoom, { passive: false }); + document.addEventListener('gesturestart', preventGestureZoom, { passive: false }); + document.addEventListener('gesturechange', preventGestureZoom, { passive: false }); + document.addEventListener('wheel', preventCtrlWheelZoom, { passive: false }); + + return () => { + document.removeEventListener('touchmove', preventMultiTouchZoom); + document.removeEventListener('gesturestart', preventGestureZoom); + document.removeEventListener('gesturechange', preventGestureZoom); + document.removeEventListener('wheel', preventCtrlWheelZoom); + }; + }, []); + + return null; +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..b380fa3 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,37 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import * as schema from './schema'; +import path from 'path'; + +function resolveDatabaseUrl() { + const configuredPath = process.env.DATABASE_URL ?? './data/pulse-clock.db'; + + if (/^(file|libsql|https?):/.test(configuredPath)) { + return configuredPath; + } + + const filePath = path.isAbsolute(configuredPath) + ? configuredPath + : path.join(/* turbopackIgnore: true */ process.cwd(), configuredPath); + + return `file:${filePath}`; +} + +const databaseUrl = resolveDatabaseUrl(); + +import fs from 'fs'; +if (databaseUrl.startsWith('file:')) { + const dataDir = path.dirname(databaseUrl.slice('file:'.length)); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } +} + +const client = createClient({ url: databaseUrl }); + +export const db = drizzle(client, { schema }); +export const dbReady = + process.env.PULSE_SKIP_DB_BOOTSTRAP === '1' + ? Promise.resolve() + : migrate(db, { migrationsFolder: path.join(process.cwd(), 'drizzle') }); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..e5c2b21 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,105 @@ +import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { relations } from 'drizzle-orm'; + +export const companies = sqliteTable('companies', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), +}); + +export const employees = sqliteTable( + 'employees', + { + id: integer('id').primaryKey({ autoIncrement: true }), + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + name: text('name').notNull(), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => ({ + companyActiveIdx: index('company_active_idx').on(table.companyId, table.isActive), + }), +); + +export const timeEntries = sqliteTable( + 'time_entries', + { + id: integer('id').primaryKey({ autoIncrement: true }), + employeeId: integer('employee_id') + .notNull() + .references(() => employees.id), + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + type: text('type', { enum: ['IN', 'OUT', 'BREAK_IN', 'BREAK_OUT'] }).notNull(), + timestamp: integer('timestamp') + .notNull() + .default(sql`(unixepoch())`), + photoBase64: text('photo_base64'), + isDeleted: integer('is_deleted', { mode: 'boolean' }).notNull().default(false), + }, + (table) => ({ + employeeTimestampIdx: index('employee_timestamp_idx').on(table.employeeId, table.timestamp), + companyTimestampIdx: index('company_timestamp_idx').on(table.companyId, table.timestamp), + }), +); + +export const employeesRelations = relations(employees, ({ one, many }) => ({ + company: one(companies, { + fields: [employees.companyId], + references: [companies.id], + }), + timeEntries: many(timeEntries), +})); + +export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({ + employee: one(employees, { + fields: [timeEntries.employeeId], + references: [employees.id], + }), + company: one(companies, { + fields: [timeEntries.companyId], + references: [companies.id], + }), +})); + +export const companiesRelations = relations(companies, ({ many }) => ({ + employees: many(employees), + timeEntries: many(timeEntries), +})); + +/** Immutable audit log , every punch (including overrides) is recorded. */ +export const auditLog = sqliteTable( + 'audit_log', + { + id: integer('id').primaryKey({ autoIncrement: true }), + /** The employee who punched */ + employeeId: integer('employee_id') + .notNull() + .references(() => employees.id), + /** Which hotel the punch was for */ + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + /** adminId=0 means kiosk punch; non-zero = admin override */ + adminId: integer('admin_id').notNull().default(0), + /** e.g. 'CLOCK_IN', 'CLOCK_OUT', 'BREAK_START', 'BREAK_END', 'OVERRIDE_IN', etc. */ + action: text('action').notNull(), + /** Human-readable detail string */ + detail: text('detail').notNull(), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => ({ + employeeIdx: index('audit_employee_idx').on(table.employeeId), + companyIdx: index('audit_company_idx').on(table.companyId), + }), +); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..19744b2 --- /dev/null +++ b/src/lib/api-response.ts @@ -0,0 +1,9 @@ +export type ApiResponse = { data: T; success: true } | { error: string; details?: unknown; success: false }; + +export function ok(data: T): ApiResponse { + return { data, success: true }; +} + +export function err(error: string, details?: unknown): ApiResponse { + return { error, details, success: false }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d540385 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,19 @@ +import { ApiResponse } from './api-response'; + +export async function fetchApi(url: string, options?: RequestInit): Promise { + const method = options?.method?.toUpperCase() ?? 'GET'; + const headers = new Headers(options?.headers); + if (method !== 'GET' && method !== 'HEAD') { + headers.set('x-pulse-csrf', '1'); + } + + const res = await fetch(url, { ...options, headers }); + const json = (await res.json()) as ApiResponse; + if (!json.success) { + throw new Error((json as { error: string }).error ?? 'API error'); + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${url}`); + } + return json.data as T; +} diff --git a/src/lib/auth-cookie.ts b/src/lib/auth-cookie.ts new file mode 100644 index 0000000..142f99e --- /dev/null +++ b/src/lib/auth-cookie.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createHash, timingSafeEqual } from 'crypto'; + +export const PULSE_AUTH_COOKIE = 'pulse_auth'; +const AUTH_VERSION = 'pulse-auth-v1'; +const MIN_SECRET_LENGTH = 32; + +export function getPulseSecret(): string | null { + const secret = process.env.PULSE_SECRET; + if (!secret || secret.length < MIN_SECRET_LENGTH) return null; + return secret; +} + +export function computeCookieValue(): string | null { + const secret = getPulseSecret(); + if (!secret) return null; + return createHash('sha256').update(secret + ':' + AUTH_VERSION).digest('hex'); +} + +export function verifyCookie(request: NextRequest): boolean { + const cookie = request.cookies.get(PULSE_AUTH_COOKIE)?.value; + if (!cookie) return false; + const expected = computeCookieValue(); + if (!expected) return false; + return cookie === expected; +} + +export function verifySecret(candidate: string): boolean { + const secret = getPulseSecret(); + if (!secret) return false; + + const candidateBuffer = Buffer.from(candidate); + const secretBuffer = Buffer.from(secret); + return candidateBuffer.length === secretBuffer.length && timingSafeEqual(candidateBuffer, secretBuffer); +} + +export function setAuthCookie(response: NextResponse): NextResponse { + const value = computeCookieValue(); + if (!value) { + throw new Error('PULSE_SECRET must be set to at least 32 characters'); + } + response.cookies.set(PULSE_AUTH_COOKIE, value, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 31536000, + path: '/', + }); + return response; +} + +export function clearAuthCookie(response: NextResponse): NextResponse { + response.cookies.set(PULSE_AUTH_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + return response; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..fa77189 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,68 @@ +/** + * Rate limiting utilities. + * + * Rate limiting: In-memory sliding window counter keyed by IP. + * Configure via PULSE_RATE_LIMIT (requests per window, default 60) + * and PULSE_RATE_WINDOW (seconds, default 60). + */ + +import { NextRequest, NextResponse } from 'next/server'; + +// ── Rate limiting ───────────────────────────────────────────────────────────── + +interface RateLimitEntry { + count: number; + resetAt: number; // unix ms +} + +const rateLimitStore = new Map(); + +function getRateLimitConfig() { + const limit = parseInt(process.env.PULSE_RATE_LIMIT ?? '60', 10); + const windowMs = parseInt(process.env.PULSE_RATE_WINDOW ?? '60', 10) * 1000; + return { limit, windowMs }; +} + +function getClientKey(request: NextRequest): string { + return request.headers.get('x-forwarded-for') ?? request.headers.get('cf-connecting-ip') ?? 'unknown'; +} + +/** + * Check and record rate limit for a request. Returns a response if limit is + * exceeded, null if the request is allowed. + */ +export function checkRateLimit(request: NextRequest): NextResponse | null { + const { limit, windowMs } = getRateLimitConfig(); + const key = getClientKey(request); + const now = Date.now(); + + const entry = rateLimitStore.get(key); + + if (!entry || now > entry.resetAt) { + // First request or window expired , start fresh + rateLimitStore.set(key, { count: 1, resetAt: now + windowMs }); + return null; + } + + if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return NextResponse.json( + { + error: 'Too many requests. Please slow down.', + retryAfter, + }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)), + }, + }, + ); + } + + entry.count++; + return null; +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts new file mode 100644 index 0000000..ad4cc6f --- /dev/null +++ b/src/lib/calculations.ts @@ -0,0 +1,186 @@ +export type CalcTimeEntry = { + id: number; + employeeId: number; + type: 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; + timestamp: number; + isDeleted: boolean; +}; + +export type DayMinutes = { + clockMinutes: number; + breakMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; // human-readable reasons for hasErrors +}; + +export type DayMinutesOptions = { + nowUnix?: number; + openShiftMaxHours?: number; +}; + +export type LiveShiftElapsed = { + shiftStartTimestamp: number | null; + activeBreakStartTimestamp: number | null; + completedBreakSeconds: number; +}; + +export function calculateLiveShiftElapsed(entries: CalcTimeEntry[]): LiveShiftElapsed { + const active = entries.filter((e) => !e.isDeleted).sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + let shiftStartTimestamp: number | null = null; + let activeBreakStartTimestamp: number | null = null; + let completedBreakSeconds = 0; + + for (const entry of active) { + switch (entry.type) { + case 'IN': + shiftStartTimestamp = entry.timestamp; + activeBreakStartTimestamp = null; + completedBreakSeconds = 0; + break; + case 'BREAK_OUT': + if (shiftStartTimestamp !== null && activeBreakStartTimestamp === null) { + activeBreakStartTimestamp = entry.timestamp; + } + break; + case 'BREAK_IN': + if (activeBreakStartTimestamp !== null) { + completedBreakSeconds += Math.max(entry.timestamp - activeBreakStartTimestamp, 0); + activeBreakStartTimestamp = null; + } + break; + case 'OUT': + shiftStartTimestamp = null; + activeBreakStartTimestamp = null; + completedBreakSeconds = 0; + break; + } + } + + return { shiftStartTimestamp, activeBreakStartTimestamp, completedBreakSeconds }; +} + +/** + * Given an employee punch timeline and one calendar day, return the + * clock/break/worked minutes whose shift segments intersect that day. + * + * Callers should pass enough surrounding entries to include overnight shifts + * that start before or end after the displayed date range. + */ +export function calculateDayMinutes( + entries: CalcTimeEntry[], + dateStr: string, + options: DayMinutesOptions = {}, +): DayMinutes { + const active = entries.filter((e) => !e.isDeleted).sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + if (active.length === 0) return { clockMinutes: 0, breakMinutes: 0, workedMinutes: 0, hasErrors: false, errors: [] }; + + const dayStart = new Date(dateStr + 'T00:00:00').getTime(); + const dayEnd = new Date(dateStr + 'T00:00:00').getTime() + 86400000; + const nowMs = (options.nowUnix ?? Math.floor(Date.now() / 1000)) * 1000; + const openShiftMaxMs = (options.openShiftMaxHours ?? 12) * 3600000; + const errors = new Set(); + let clockMs = 0; + let breakMs = 0; + let shiftStart: number | null = null; + let breakStart: number | null = null; + let previousType: CalcTimeEntry['type'] | null = null; + + const overlapsDay = (start: number, end: number) => Math.min(end, dayEnd) > Math.max(start, dayStart); + const eventIsInDay = (ts: number) => ts >= dayStart && ts < dayEnd; + const addIssue = (message: string, relevantAt: number | null = null) => { + if (relevantAt === null || eventIsInDay(relevantAt)) errors.add(message); + }; + const addOverlap = (start: number, end: number, kind: 'clock' | 'break') => { + const overlapStart = Math.max(start, dayStart); + const overlapEnd = Math.min(end, dayEnd); + if (overlapEnd <= overlapStart) return; + if (kind === 'clock') clockMs += overlapEnd - overlapStart; + if (kind === 'break') breakMs += overlapEnd - overlapStart; + }; + + for (const entry of active) { + const ts = entry.timestamp * 1000; + + if (previousType === entry.type) { + addIssue(`Consecutive ${entry.type} entries`, ts); + } + + switch (entry.type) { + case 'IN': + if (shiftStart !== null) addIssue('Clock In before Clock Out', ts); + if (breakStart !== null) { + addIssue('BREAK_IN missing before IN', ts); + breakStart = null; + } + shiftStart = ts; + break; + case 'BREAK_OUT': + if (shiftStart === null) { + addIssue('Break started without Clock In', ts); + } else if (breakStart !== null) { + addIssue('Break started without break end', ts); + addOverlap(breakStart, ts, 'break'); + } + breakStart = ts; + break; + case 'BREAK_IN': + if (shiftStart === null) { + addIssue('Break end without Clock In', ts); + } else if (breakStart === null) { + addIssue('Break end without break start', ts); + } else { + addOverlap(breakStart, ts, 'break'); + breakStart = null; + } + break; + case 'OUT': + if (shiftStart === null) { + addIssue('Clock out without Clock In', ts); + } else { + addOverlap(shiftStart, ts, 'clock'); + if (breakStart !== null) { + addIssue('Break started but never ended', overlapsDay(breakStart, ts) ? null : ts); + addOverlap(breakStart, ts, 'break'); + } + shiftStart = null; + breakStart = null; + } + break; + } + + previousType = entry.type; + } + + if (shiftStart !== null) { + const openEnd = Math.max(shiftStart, Math.min(nowMs, shiftStart + openShiftMaxMs)); + if (overlapsDay(shiftStart, openEnd)) { + errors.add('Still clocked in'); + addOverlap(shiftStart, openEnd, 'clock'); + if (breakStart !== null) { + errors.add('Break started but never ended'); + addOverlap(breakStart, openEnd, 'break'); + } + } + } + + const clockMinutes = Math.round(clockMs / 60000); + const breakMinutes = Math.round(breakMs / 60000); + const workedMinutes = clockMinutes - breakMinutes; + return { + clockMinutes, + breakMinutes, + workedMinutes, + hasErrors: errors.size > 0, + errors: Array.from(errors), + }; +} + +export function formatHours(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + if (hours === 0 && mins === 0) return '-'; + if (hours === 0) return `${mins}m`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} diff --git a/src/lib/params.ts b/src/lib/params.ts new file mode 100644 index 0000000..53d8467 --- /dev/null +++ b/src/lib/params.ts @@ -0,0 +1,5 @@ +export function parseId(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..09c2a82 --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +export const PunchType = z.enum(['IN', 'OUT', 'BREAK_IN', 'BREAK_OUT']); +export type PunchType = z.infer; + +export const CompanySchema = z.object({ + name: z + .string() + .min(1, 'Company name is required') + .transform((v) => v.trim()), +}); + +export const CompanyUpdateSchema = z.object({ + id: z.number(), + name: z + .string() + .min(1, 'Company name is required') + .transform((v) => v.trim()), +}); + +export const EmployeeSchema = z.object({ + companyId: z.number(), + name: z.string().min(1, 'Employee name is required'), +}); + +export const EmployeeUpdateSchema = z.object({ + id: z.number(), + name: z.string().min(1).optional(), + isActive: z.boolean().optional(), +}); + +export const PunchSchema = z.object({ + employeeId: z.number(), + companyId: z.number(), + type: PunchType, + photoBase64: z.string().optional(), +}); + +export const ManualEntrySchema = z + .object({ + employeeId: z.number(), + companyId: z.number(), + type: PunchType, + timestamp: z.number().int(), + photoBase64: z.string().optional(), + forceOverride: z.boolean().optional(), + }) + .superRefine((entry, ctx) => { + const now = Math.floor(Date.now() / 1000); + if (entry.timestamp < now - 31536000) { + ctx.addIssue({ + code: 'too_small', + minimum: now - 31536000, + inclusive: true, + origin: 'number', + path: ['timestamp'], + message: 'Timestamp cannot be more than one year in the past', + }); + } + if (entry.timestamp > now + 86400) { + ctx.addIssue({ + code: 'too_big', + maximum: now + 86400, + inclusive: true, + origin: 'number', + path: ['timestamp'], + message: 'Timestamp cannot be more than one day in the future', + }); + } + }); + +export const TimeEntryUpdateSchema = z.object({ + id: z.number(), + isDeleted: z.boolean().optional(), + timestamp: z.number().optional(), + forceOverride: z.boolean().optional(), +}); diff --git a/src/lib/time-entry-service.ts b/src/lib/time-entry-service.ts new file mode 100644 index 0000000..b6c9ac6 --- /dev/null +++ b/src/lib/time-entry-service.ts @@ -0,0 +1,117 @@ +import { and, eq } from 'drizzle-orm'; +import { db, dbReady } from '@/db'; +import { auditLog, employees, timeEntries } from '@/db/schema'; +import { AUDIT_ACTIONS, PUNCH_LABELS, statusAfter, validateNewEntryFromHistory } from './validation'; +import type { PunchType } from './schemas'; + +const DUPLICATE_PUNCH_WINDOW_SECONDS = 5; + +type CreateTimeEntryInput = { + employeeId: number; + companyId: number; + type: PunchType; + timestamp?: number; + photoBase64?: string | null; + forceOverride?: boolean; + source: 'kiosk' | 'manual'; +}; + +export type CreateTimeEntryResult = + | { + ok: true; + entry: Pick< + typeof timeEntries.$inferSelect, + 'id' | 'employeeId' | 'companyId' | 'type' | 'timestamp' | 'photoBase64' | 'isDeleted' + >; + status: ReturnType; + overridden: boolean; + } + | { ok: false; status: number; error: string }; + +export async function createValidatedTimeEntry(input: CreateTimeEntryInput): Promise { + await dbReady; + return db.transaction(async (tx) => { + const employee = await tx + .select() + .from(employees) + .where(and(eq(employees.id, input.employeeId), eq(employees.companyId, input.companyId))) + .get(); + + if (!employee) { + return { ok: false, status: 404, error: 'Employee not found or does not belong to this company' }; + } + + const timestamp = input.timestamp ?? Math.floor(Date.now() / 1000); + const entries = await tx + .select({ + id: timeEntries.id, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.employeeId, input.employeeId), + eq(timeEntries.companyId, input.companyId), + eq(timeEntries.isDeleted, false), + ), + ) + .orderBy(timeEntries.timestamp, timeEntries.id) + .all(); + + const validation = validateNewEntryFromHistory(entries, input.type, timestamp, { + forceOverride: !!input.forceOverride, + duplicateWindowSeconds: input.source === 'kiosk' ? DUPLICATE_PUNCH_WINDOW_SECONDS : 0, + requireIncreasingTimestamp: input.source === 'manual', + }); + if (!validation.valid) { + return { ok: false, status: 400, error: validation.error ?? 'Validation failed' }; + } + + const entry = await tx + .insert(timeEntries) + .values({ + employeeId: input.employeeId, + companyId: input.companyId, + type: input.type, + timestamp, + photoBase64: input.photoBase64 || null, + }) + .returning() + .get(); + + const overrideActions = { + IN: 'OVERRIDE_IN', + OUT: 'OVERRIDE_OUT', + BREAK_OUT: 'OVERRIDE_BREAK_START', + BREAK_IN: 'OVERRIDE_BREAK_END', + } as const satisfies Record; + const auditKey = input.forceOverride ? overrideActions[input.type] : input.type; + const action = AUDIT_ACTIONS[auditKey] ?? input.type; + const detail = + input.source === 'kiosk' + ? `Kiosk punch: ${PUNCH_LABELS[input.type]}` + : input.forceOverride + ? `Admin override: ${input.type} at ${new Date(timestamp * 1000).toLocaleString()}` + : `Manual entry: ${input.type} at ${new Date(timestamp * 1000).toLocaleString()}`; + + await tx + .insert(auditLog) + .values({ + employeeId: input.employeeId, + companyId: input.companyId, + adminId: input.source === 'kiosk' ? 0 : 1, + action, + detail, + }) + .run(); + + return { + ok: true, + entry, + status: statusAfter(input.type), + overridden: !!input.forceOverride, + }; + }); +} diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..6222076 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,58 @@ +import { format } from 'date-fns'; + +/** + * Convert a Unix timestamp (seconds since epoch) to a JS Date. + */ +function fromUnix(unixSeconds: number): Date { + return new Date(unixSeconds * 1000); +} + +/** + * Format a Unix timestamp as a time string (e.g. "2:30 PM"). + */ +export function formatTime(unixSeconds: number, fmt: string = 'h:mm a'): string { + return format(fromUnix(unixSeconds), fmt); +} + +/** + * Format a Unix timestamp as a date string (e.g. "May 10, 2026"). + */ +export function formatDate(unixSeconds: number, fmt: string = 'MMM d, yyyy'): string { + return format(fromUnix(unixSeconds), fmt); +} + +/** + * Given a Date, return the Unix timestamp range for that calendar day + * (local midnight to 23:59:59). + */ +export function dayRange(date: Date): { start: number; end: number } { + const start = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000); + const end = start + 86400 - 1; + return { start, end }; +} + +/** + * Given a Date (local midnight for a day), return the full Unix second for + * the start of that day. + */ +export function toUnix(date: Date): number { + return Math.floor(date.getTime() / 1000); +} + +/** + * Convert a `datetime-local` input value to a Unix timestamp. + */ +export function fromInput(inputValue: string): number { + const parsed = new Date(inputValue); + if (isNaN(parsed.getTime())) { + throw new Error(`Invalid date input: ${inputValue}`); + } + return Math.floor(parsed.getTime() / 1000); +} + +/** + * ISO date key (e.g. "2026-05-10") , for grouping entries by day. + */ +export function dateKey(unixSeconds: number): string { + return format(fromUnix(unixSeconds), 'yyyy-MM-dd'); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..5055529 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,154 @@ +import { timeEntries } from '@/db/schema'; +import { PunchType } from './schemas'; + +// PunchType: single source of truth is schemas.ts (Zod schema + z.infer type). +// This file re-exports it so callers can import from one place. +export type { PunchType } from './schemas'; + +export type EmployeeStatus = 'IN' | 'OUT' | 'BREAK'; +export type ValidationEntry = { + id: number; + type: PunchType; + timestamp: number; + isDeleted: boolean; +}; + +/** Human-readable labels for punch types */ +export const PUNCH_LABELS: Record = { + IN: 'Clock In', + OUT: 'Clock Out', + BREAK_OUT: 'Break Start', + BREAK_IN: 'Break End', +}; + +export const AUDIT_ACTIONS = { + IN: 'CLOCK_IN', + OUT: 'CLOCK_OUT', + BREAK_OUT: 'BREAK_START', + BREAK_IN: 'BREAK_END', + OVERRIDE_IN: 'OVERRIDE_IN', + OVERRIDE_OUT: 'OVERRIDE_OUT', + OVERRIDE_BREAK_START: 'OVERRIDE_BREAK_START', + OVERRIDE_BREAK_END: 'OVERRIDE_BREAK_END', +} as const; + +/** Maps a PunchType value to the resulting EmployeeStatus */ +export function statusAfter(type: PunchType): EmployeeStatus { + switch (type) { + case 'IN': + return 'IN'; + case 'OUT': + return 'OUT'; + case 'BREAK_OUT': + return 'BREAK'; // employee stepped out → on break + case 'BREAK_IN': + return 'IN'; // employee returned from break + } +} + +// --------------------------------------------------------------------------- +// Core state machine rules +// --------------------------------------------------------------------------- + +type ValidationResult = { valid: boolean; error?: string; currentStatus?: EmployeeStatus }; + +/** Rules enforced by the state machine. Used by both kiosk punches and + * manual/admin entries. Pass `forceOverride=true` to bypass. */ +function validatePunchRules(currentStatus: EmployeeStatus, type: PunchType, forceOverride = false): ValidationResult { + if (forceOverride) return { valid: true, currentStatus }; + + switch (type) { + case 'IN': + if (currentStatus === 'IN') return { valid: false, error: 'Already on shift , clock out first', currentStatus }; + if (currentStatus === 'BREAK') + return { valid: false, error: 'Already on break , end break first', currentStatus }; + break; + case 'OUT': + if (currentStatus === 'OUT') return { valid: false, error: 'Not clocked in , clock in first', currentStatus }; + if (currentStatus === 'BREAK') + return { valid: false, error: 'On break , end break before clocking out', currentStatus }; + break; + case 'BREAK_OUT': + if (currentStatus !== 'IN') return { valid: false, error: 'Must be on shift to start a break', currentStatus }; + break; + case 'BREAK_IN': + if (currentStatus !== 'BREAK') return { valid: false, error: 'Not on break , start break first', currentStatus }; + break; + } + return { valid: true, currentStatus }; +} + +export function validateNewEntryFromHistory( + entries: ValidationEntry[], + type: PunchType, + timestamp: number, + options: { forceOverride?: boolean; duplicateWindowSeconds?: number; requireIncreasingTimestamp?: boolean } = {}, +): ValidationResult { + const activeEntries = entries + .filter((entry) => !entry.isDeleted) + .sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + + if (activeEntries.length === 0) { + if (type !== 'IN' && !options.forceOverride) { + return { valid: false, error: 'No shift found , clock in first', currentStatus: 'OUT' }; + } + return { valid: true, currentStatus: 'OUT' }; + } + + const lastEntry = activeEntries[activeEntries.length - 1]!; + const currentStatus = statusAfter(lastEntry.type); + + if (options.forceOverride) { + return { valid: true, currentStatus }; + } + + const duplicateWindowSeconds = options.duplicateWindowSeconds ?? 0; + if ( + duplicateWindowSeconds > 0 && + lastEntry.type === type && + Math.abs(timestamp - lastEntry.timestamp) <= duplicateWindowSeconds + ) { + return { valid: false, error: 'Duplicate punch ignored , the same action was just recorded', currentStatus }; + } + + if (options.requireIncreasingTimestamp !== false && timestamp <= lastEntry.timestamp) { + return { + valid: false, + error: `Timestamp must be after last entry (${formatTs(lastEntry.timestamp)})`, + currentStatus, + }; + } + + return validatePunchRules(currentStatus, type); +} + +/** Shifting an entry's timestamp must still fit between neighbours. */ +export function validateTimestampChronology( + entries: (typeof timeEntries.$inferSelect)[], + entryId: number, + newTimestamp: number, + forceOverride = false, +): { valid: boolean; error?: string } { + const allEntries = entries.toSorted((a, b) => a.timestamp - b.timestamp || a.id - b.id); + const idx = allEntries.findIndex((e) => e.id === entryId); + if (idx === -1) return { valid: false, error: 'Entry not found' }; + + const prev = idx > 0 ? allEntries[idx - 1] : null; + const next = idx < allEntries.length - 1 ? allEntries[idx + 1] : null; + + if (prev && newTimestamp <= prev.timestamp && !forceOverride) { + return { valid: false, error: 'Timestamp must be after the previous entry' }; + } + if (next && newTimestamp >= next.timestamp && !forceOverride) { + return { valid: false, error: 'Timestamp must be before the next entry' }; + } + return { valid: true }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- +function formatTs(ts: number): string { + const d = new Date(ts * 1000); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..10ab202 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,165 @@ +/** + * Next.js proxy, handles CORS, global rate limiting, and cookie auth for all API routes. + * + * CORS: Allows requests from: + * - http://localhost:* (development) + * - /kiosk/[companyId] pages (same-site, kiosk mode) + * + * Rate limiting: Applies to all /api/* routes using the sliding window + * counter from @/lib/auth. Uses x-forwarded-for / cf-connecting-ip to + * key clients. Configure via PULSE_RATE_LIMIT and PULSE_RATE_WINDOW env vars. + * + * Cookie auth: All /api/* routes (except /api/authorize) require a valid + * pulse_auth cookie computed as HMAC-SHA256(PULSE_SECRET). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit } from '@/lib/auth'; +import { verifyCookie } from '@/lib/auth-cookie'; + +const ALLOWED_METHODS = ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']; +const ALLOWED_HEADERS = ['Content-Type', 'x-requested-with', 'x-pulse-csrf']; +const PUBLIC_PATHS = new Set(['/login', '/api/authorize', '/api/health']); +const WRITE_METHODS = new Set(['POST', 'PATCH', 'DELETE']); + +function corsHeaders(request: NextRequest) { + return { + 'Access-Control-Allow-Origin': getCorsOrigin(request.headers.get('origin'), request), + 'Access-Control-Allow-Methods': ALLOWED_METHODS.join(', '), + 'Access-Control-Allow-Headers': ALLOWED_HEADERS.join(', '), + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Max-Age': '86400', + }; +} + +export function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (request.method === 'OPTIONS') { + return new NextResponse(null, { status: 204, headers: corsHeaders(request) }); + } + + if (pathname === '/api/health') { + const response = NextResponse.next(); + for (const [key, value] of Object.entries(corsHeaders(request))) { + response.headers.set(key, value); + } + return response; + } + + if (pathname === '/api/authorize') { + const rateLimitError = checkRateLimit(request); + if (rateLimitError) return withCors(rateLimitError, request); + if (WRITE_METHODS.has(request.method) && !isTrustedWriteRequest(request)) { + return withCors( + NextResponse.json({ error: 'Forbidden', code: 'CSRF_BLOCKED' }, { status: 403 }), + request, + ); + } + + const response = NextResponse.next(); + return withCors(response, request); + } + + if (pathname.startsWith('/api/')) { + if (!verifyCookie(request)) { + return withCors( + NextResponse.json({ error: 'Unauthorized', code: 'AUTH_REQUIRED' }, { status: 401 }), + request, + ); + } + + if (WRITE_METHODS.has(request.method) && !isTrustedWriteRequest(request)) { + return withCors( + NextResponse.json({ error: 'Forbidden', code: 'CSRF_BLOCKED' }, { status: 403 }), + request, + ); + } + + const rateLimitError = checkRateLimit(request); + if (rateLimitError) return withCors(rateLimitError, request); + + const response = NextResponse.next(); + return withCors(response, request); + } + + if (PUBLIC_PATHS.has(pathname)) { + return NextResponse.next(); + } + + if (!verifyCookie(request)) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('next', request.nextUrl.pathname + request.nextUrl.search); + return NextResponse.redirect(loginUrl); + } + + if (pathname === '/') { + return NextResponse.redirect(new URL('/admin', request.url)); + } + + if (pathname === '/kiosk') { + return NextResponse.redirect(new URL('/admin/manage', request.url)); + } + + return NextResponse.next(); +} + +function withCors(response: NextResponse, request: NextRequest): NextResponse { + for (const [key, value] of Object.entries(corsHeaders(request))) { + response.headers.set(key, value); + } + return response; +} + +function getCorsOrigin(requestedOrigin: string | null, request: NextRequest): string { + const isDev = process.env.NODE_ENV !== 'production'; + + if (isDev) { + if (requestedOrigin) { + if (URL.canParse(requestedOrigin)) { + const url = new URL(requestedOrigin); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + return requestedOrigin; + } + } + } + + const host = request.headers.get('host'); + if (host && (host.includes('localhost') || host.includes('127.0.0.1'))) { + return `http://${host}`; + } + return 'http://localhost:3000'; + } + + if (requestedOrigin && isSameOrigin(requestedOrigin, request)) { + return requestedOrigin; + } + + const host = request.headers.get('host'); + if (!requestedOrigin && host) { + return `https://${host}`; + } + + return 'null'; +} + +function isSameOrigin(origin: string, request: NextRequest): boolean { + if (!URL.canParse(origin)) return false; + const originUrl = new URL(origin); + const host = request.headers.get('host'); + return originUrl.host === host; +} + +function isTrustedWriteRequest(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + if (origin && !isSameOrigin(origin, request)) return false; + + const csrfHeader = request.headers.get('x-pulse-csrf'); + const fetchSite = request.headers.get('sec-fetch-site'); + if (csrfHeader === '1') return true; + return fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none'; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..24b9147 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +}