initial: pulsy from pulse-clock-main
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+57
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
package-lock.json
|
||||||
|
data/
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
+37
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1779035877611,
|
||||||
|
"tag": "0000_chubby_toro",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<T>() 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;
|
||||||
@@ -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;
|
||||||
Generated
+5989
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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');
|
||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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<Company[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>('');
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
|
||||||
|
const [editingCompany, setEditingCompany] = useState<number | null>(null);
|
||||||
|
const [editingCompanyName, setEditingCompanyName] = useState('');
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<number | null>(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<string | null>(null);
|
||||||
|
|
||||||
|
const fail = (fallback: string, caught: unknown): never => {
|
||||||
|
setError(caught instanceof Error ? caught.message : fallback);
|
||||||
|
throw caught;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCompanies = useCallback(async () => {
|
||||||
|
setCompanies(await fetchApi<Company[]>('/api/companies'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEmployees = useCallback(async (companyId: string) => {
|
||||||
|
setEmployees(await fetchApi<Employee[]>(`/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 (
|
||||||
|
<div className="min-h-screen bg-base-200">
|
||||||
|
<header className="bg-base-100 border-b border-base-300 px-4 py-3 sticky top-0 z-10">
|
||||||
|
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">⚙️</span>
|
||||||
|
<h1 className="font-semibold text-lg">Manage Hotels & Employees</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href="/admin">
|
||||||
|
<button className="btn btn-outline btn-sm">
|
||||||
|
<ArrowLeft className="size-4" /> Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto p-4 space-y-8">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error text-sm">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hotels Section */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Building2 className="size-5" /> Hotels
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => setShowNewCompany(!showNewCompany)}>
|
||||||
|
<Plus className="size-4" /> Add Hotel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewCompany && (
|
||||||
|
<div className="card bg-base-100 border border-base-300 p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="label" htmlFor="new-company-name">
|
||||||
|
<span className="label-text">Hotel Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-company-name"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="e.g. Hyatt Place - Airport"
|
||||||
|
value={newCompanyName}
|
||||||
|
onChange={(e) => setNewCompanyName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateCompany()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreateCompany} disabled={!newCompanyName.trim()}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setShowNewCompany(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table className="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th className="text-right w-48">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>
|
||||||
|
{editingCompany === c.id ? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-sm"
|
||||||
|
value={editingCompanyName}
|
||||||
|
onChange={(e) => setEditingCompanyName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleUpdateCompany(c.id)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-success"
|
||||||
|
onClick={() => handleUpdateCompany(c.id)}
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-square btn-xs" onClick={() => setEditingCompany(null)}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-base-content/60">#{c.id}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<Link
|
||||||
|
href={`/kiosk/${c.id}`}
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-primary"
|
||||||
|
title={`Open kiosk for ${c.name}`}
|
||||||
|
aria-label={`Open kiosk for ${c.name}`}
|
||||||
|
>
|
||||||
|
<Monitor className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCompany(c.id);
|
||||||
|
setEditingCompanyName(c.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-error"
|
||||||
|
onClick={() => handleDeleteCompany(c.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{companies.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center text-base-content/40 py-8">
|
||||||
|
No hotels yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
{/* Employees Section */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Users className="size-5" /> Employees
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm w-48"
|
||||||
|
value={selectedCompany}
|
||||||
|
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select a hotel…</option>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<option key={c.id} value={c.id.toString()}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setShowNewEmployee(!showNewEmployee)}
|
||||||
|
disabled={!selectedCompany}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" /> Add Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewEmployee && selectedCompany && (
|
||||||
|
<div className="card bg-base-100 border border-base-300 p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="label" htmlFor="new-employee-name">
|
||||||
|
<span className="label-text">Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-employee-name"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="e.g. John Doe"
|
||||||
|
value={newEmployeeName}
|
||||||
|
onChange={(e) => setNewEmployeeName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateEmployee()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreateEmployee} disabled={!newEmployeeName.trim()}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setShowNewEmployee(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table className="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="text-right w-40">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{employees.map((emp) => (
|
||||||
|
<tr key={emp.id} className={!emp.isActive ? 'opacity-50' : ''}>
|
||||||
|
<td>
|
||||||
|
{editingEmployee === emp.id ? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-sm"
|
||||||
|
value={editingEmployeeName}
|
||||||
|
onChange={(e) => setEditingEmployeeName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleUpdateEmployee(emp.id)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-success"
|
||||||
|
onClick={() => handleUpdateEmployee(emp.id)}
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-square btn-xs" onClick={() => setEditingEmployee(null)}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{emp.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className={`badge ${emp.isActive ? 'badge-success' : 'badge-ghost'}`}
|
||||||
|
onClick={() => handleToggleActive(emp)}
|
||||||
|
>
|
||||||
|
{emp.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEmployee(emp.id);
|
||||||
|
setEditingEmployeeName(emp.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-error"
|
||||||
|
onClick={() => handleDeleteEmployee(emp.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!selectedCompany && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center text-base-content/40 py-8">
|
||||||
|
Select a hotel to see employees
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{selectedCompany && employees.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center text-base-content/40 py-8">
|
||||||
|
No employees yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close details"
|
||||||
|
className="absolute inset-0 bg-black/50 cursor-default"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="bg-base-100 rounded-t-2xl sm:rounded-2xl shadow-2xl p-6 w-full max-w-lg relative z-10 max-h-[88vh] overflow-y-auto">
|
||||||
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={onClose}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Company[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>('');
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [allEntries, setAllEntries] = useState<TimeEntry[]>([]);
|
||||||
|
const [startDate, setStartDate] = useState<string>(() => dateInputValue(subDays(new Date(), 13)));
|
||||||
|
const [endDate, setEndDate] = useState<string>(() => 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<Set<number>>(new Set());
|
||||||
|
const [editTimestamps, setEditTimestamps] = useState<Record<number, string>>({});
|
||||||
|
const [editingOverride, setEditingOverride] = useState<Record<number, boolean>>({});
|
||||||
|
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
|
||||||
|
const [manualPunchError, setManualPunchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setSheetOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApi<Company[]>('/api/companies')
|
||||||
|
.then((data) => {
|
||||||
|
setCompanies(data);
|
||||||
|
setSelectedCompany((current) => current || data[0]?.id.toString() || '');
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCompany) return;
|
||||||
|
fetchApi<Employee[]>(`/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<TimeEntry[]>(
|
||||||
|
`/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<number, Record<string, DayEntries>> = {};
|
||||||
|
for (const emp of employees) {
|
||||||
|
const empData: Record<string, DayEntries> = {};
|
||||||
|
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<number>();
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||||
|
<div className="card bg-base-100 shadow-xl p-8 w-full max-w-md text-center space-y-4">
|
||||||
|
<div className="text-5xl">📊</div>
|
||||||
|
<h1 className="text-2xl font-semibold">Admin Dashboard</h1>
|
||||||
|
<p className="text-base-content/60">No hotels yet.</p>
|
||||||
|
<Link href="/admin/manage">
|
||||||
|
<button className="btn btn-primary w-full">
|
||||||
|
<Building2 className="size-4" /> Go to Manage
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryDotColors: Record<string, string> = {
|
||||||
|
IN: 'bg-success',
|
||||||
|
OUT: 'bg-error',
|
||||||
|
BREAK_OUT: 'bg-warning',
|
||||||
|
BREAK_IN: 'bg-warning',
|
||||||
|
};
|
||||||
|
const entryLabels: Record<string, string> = {
|
||||||
|
IN: 'Clock In',
|
||||||
|
OUT: 'Clock Out',
|
||||||
|
BREAK_OUT: 'Break Start',
|
||||||
|
BREAK_IN: 'Break End',
|
||||||
|
};
|
||||||
|
const entryIcons: Record<string, string> = { IN: '🟢', OUT: '🔴', BREAK_OUT: '🟡', BREAK_IN: '🟡' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-200">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-base-100 border-b border-base-300 px-4 py-3 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">📊</span>
|
||||||
|
<h1 className="font-semibold text-lg">Pulse Clock · Admin</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={selectedCompany}
|
||||||
|
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
|
||||||
|
className="select select-bordered select-sm w-44"
|
||||||
|
>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<option key={c.id} value={c.id.toString()}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => e.target.value && setStartDate(e.target.value)}
|
||||||
|
className="input input-bordered input-sm w-36"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-base-content/40">–</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => e.target.value && setEndDate(e.target.value)}
|
||||||
|
className="input input-bordered input-sm w-36"
|
||||||
|
/>
|
||||||
|
<div className="join">
|
||||||
|
<button className="btn btn-outline btn-square btn-sm join-item" onClick={() => shiftWindow(-1)}>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline btn-square btn-sm join-item" onClick={() => shiftWindow(1)}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={loadEntries}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${showIssuesOnly ? 'btn-warning' : 'btn-outline'}`}
|
||||||
|
onClick={() => setShowIssuesOnly((v) => !v)}
|
||||||
|
>
|
||||||
|
<Filter className="size-4" /> {showIssuesOnly ? 'Show All' : 'Issues Only'}
|
||||||
|
</button>
|
||||||
|
<Link href="/admin/payroll">
|
||||||
|
<button className="btn btn-outline btn-sm">
|
||||||
|
<Download className="size-4" /> Payroll
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/manage">
|
||||||
|
<button className="btn btn-outline btn-sm">
|
||||||
|
<Settings className="size-4" /> Manage
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Date presets */}
|
||||||
|
<div className="max-w-7xl mx-auto flex gap-1 mt-2 flex-wrap">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button key={p.label} className="btn btn-ghost btn-xs" onClick={() => applyPreset(p)}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Issue Summary Banner , prominent */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 pt-4">
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl p-4 flex items-center gap-4 border-2 ${
|
||||||
|
issueStats.totalErrors === 0
|
||||||
|
? 'bg-success/10 border-success/30 text-success'
|
||||||
|
: 'bg-warning/10 border-warning/40 text-warning'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`text-4xl ${issueStats.totalErrors === 0 ? '' : 'shrink-0'}`}>
|
||||||
|
{issueStats.totalErrors === 0 ? '✅' : <AlertTriangle className="size-8" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
{issueStats.totalErrors === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xl font-semibold">All Clear</div>
|
||||||
|
<div className="text-sm opacity-80">
|
||||||
|
{employees.length} employee{employees.length !== 1 ? 's' : ''} tracked · {days.length} day
|
||||||
|
{days.length !== 1 ? 's' : ''} · 0 issues found
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xl font-semibold">
|
||||||
|
⚠️ {issueStats.totalErrors} Issue{issueStats.totalErrors !== 1 ? 's' : ''} Found
|
||||||
|
</div>
|
||||||
|
<div className="text-sm opacity-80">
|
||||||
|
Across {issueStats.employeeCount} employee{issueStats.employeeCount !== 1 ? 's' : ''} · Click any
|
||||||
|
flagged cell or{' '}
|
||||||
|
<button
|
||||||
|
className="underline hover:no-underline font-semibold"
|
||||||
|
onClick={() => setShowIssuesOnly(true)}
|
||||||
|
>
|
||||||
|
show issues only
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{issueStats.totalErrors > 0 && (
|
||||||
|
<button className="btn btn-warning btn-sm" onClick={() => setShowIssuesOnly(true)}>
|
||||||
|
<Filter className="size-4" /> Show Issues
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Grid */}
|
||||||
|
<div className="p-4 max-w-7xl mx-auto overflow-x-auto">
|
||||||
|
<div className="min-w-[800px]">
|
||||||
|
{/* Header row */}
|
||||||
|
<div
|
||||||
|
className="grid gap-px bg-base-300 rounded-t-lg overflow-hidden"
|
||||||
|
style={{ gridTemplateColumns: `180px repeat(${days.length}, minmax(52px, 1fr)) 96px 96px 96px` }}
|
||||||
|
>
|
||||||
|
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold flex items-center">Employee</div>
|
||||||
|
{days.map((day) => (
|
||||||
|
<div
|
||||||
|
key={dateKey(toUnix(day))}
|
||||||
|
className={`p-2 text-xs font-semibold text-center ${['Saturday', 'Sunday'].includes(formatDate(toUnix(day), 'EEEE')) ? 'bg-neutral/60 text-neutral-content/60' : 'bg-neutral text-neutral-content'}`}
|
||||||
|
>
|
||||||
|
<div>{formatDate(toUnix(day), 'EEE')}</div>
|
||||||
|
<div>{formatDate(toUnix(day), 'MM/dd')}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
|
||||||
|
Clock Hrs
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
|
||||||
|
Break
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
|
||||||
|
Net Hrs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
key={emp.id}
|
||||||
|
className="grid gap-px bg-base-300"
|
||||||
|
style={{ gridTemplateColumns: `180px repeat(${days.length}, minmax(52px, 1fr)) 96px 96px 96px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-base-100 p-2 text-sm font-medium flex items-center truncate ${hasAnyError ? 'text-warning' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{emp.name}</span>
|
||||||
|
{hasAnyError && <AlertTriangle className="size-3 ml-1 shrink-0 text-warning" />}
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={`${emp.id}-${dateStr}`}
|
||||||
|
onClick={() => openDetailDrawer(emp, dateStr)}
|
||||||
|
className={`bg-base-100 p-1 text-[11px] text-center hover:bg-primary/5 relative transition-colors ${
|
||||||
|
!hasActivity ? 'text-base-content/20' : hasErr ? 'bg-red-50 text-red-700' : 'text-base-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasActivity ? (
|
||||||
|
<div className="flex flex-col items-center leading-tight">
|
||||||
|
<span className="font-semibold">{formatHours(wMin) === '-' ? '0m' : formatHours(wMin)}</span>
|
||||||
|
{bMin > 0 && <span className="text-[9px] opacity-60">{formatHours(bMin)} brk</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold">-</span>
|
||||||
|
)}
|
||||||
|
{/* Error badge */}
|
||||||
|
{hasErr && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 size-4 bg-error text-white text-[9px] font-semibold rounded-full flex items-center justify-center shadow">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center">
|
||||||
|
{totals.hasActivity && formatHours(totals.clockMinutes) === '-'
|
||||||
|
? '0m'
|
||||||
|
: formatHours(totals.clockMinutes)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center text-warning">
|
||||||
|
{totals.hasActivity && formatHours(totals.breakMinutes) === '-'
|
||||||
|
? '0m'
|
||||||
|
: formatHours(totals.breakMinutes)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center text-success">
|
||||||
|
{totals.hasActivity && formatHours(totals.workedMinutes) === '-'
|
||||||
|
? '0m'
|
||||||
|
: formatHours(totals.workedMinutes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{visibleEmployees.length === 0 && (
|
||||||
|
<div className="bg-base-100 p-12 text-center text-base-content/40">
|
||||||
|
<div className="text-4xl mb-2">👥</div>
|
||||||
|
<p>
|
||||||
|
{showIssuesOnly
|
||||||
|
? 'No issues found for any employee in this date range! 🎉'
|
||||||
|
: 'No employees found for this hotel. Add employees in the Manage page.'}
|
||||||
|
</p>
|
||||||
|
{showIssuesOnly && (
|
||||||
|
<button className="btn btn-outline btn-sm mt-3" onClick={() => setShowIssuesOnly(false)}>
|
||||||
|
Show All Employees
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Drawer */}
|
||||||
|
<Drawer open={sheetOpen} onClose={() => setSheetOpen(false)}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="font-semibold text-lg">{selectedDayEntries?.employeeName}</h3>
|
||||||
|
<span className="badge badge-neutral badge-outline text-xs">{selectedDayEntries?.date}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-base-content/60 mb-4">
|
||||||
|
{selectedDayEntries?.entries.filter((e) => !e.isDeleted).length
|
||||||
|
? `${selectedDayEntries!.entries.filter((e) => !e.isDeleted).length} entries`
|
||||||
|
: 'No entries'}{' '}
|
||||||
|
for this day
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Summary strip */}
|
||||||
|
{selectedDayEntries && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-4 p-3 bg-base-200 rounded-lg text-center text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/50 mb-0.5">Clock Hours</div>
|
||||||
|
<div className="font-semibold flex items-center justify-center gap-1">
|
||||||
|
<Clock className="size-3" />{' '}
|
||||||
|
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.clockMinutes || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/50 mb-0.5">Break</div>
|
||||||
|
<div className="font-semibold flex items-center justify-center gap-1 text-warning">
|
||||||
|
<Coffee className="size-3" />{' '}
|
||||||
|
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.breakMinutes || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/50 mb-0.5">Net Worked</div>
|
||||||
|
<div className="font-semibold flex items-center justify-center gap-1 text-success">
|
||||||
|
<Zap className="size-3" />{' '}
|
||||||
|
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.workedMinutes || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issue summary section */}
|
||||||
|
{selectedDayEntries && selectedDayEntries.errors.length > 0 && (
|
||||||
|
<div className="mb-4 p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="size-4 text-error" />
|
||||||
|
<span className="font-semibold text-sm text-error">Issues Found</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selectedDayEntries.errors.map((err) => (
|
||||||
|
<li key={err} className="text-xs text-error/80 flex items-start gap-1.5">
|
||||||
|
<span className="mt-0.5">•</span>
|
||||||
|
<span>{err}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit timeline */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<h4 className="text-xs font-semibold text-base-content/40 uppercase tracking-wider">Punch Timeline</h4>
|
||||||
|
{(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 (
|
||||||
|
<div key={entry.id} className={`flex gap-3 items-start ${entry.isDeleted ? 'opacity-40' : ''}`}>
|
||||||
|
{!entry.isDeleted && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`size-3 rounded-full border-2 border-base-100 shadow shrink-0 ${entryDotColors[entry.type]}`}
|
||||||
|
/>
|
||||||
|
{idx < activeEntries.length - 1 && idx >= 0 && (
|
||||||
|
<div className="w-0.5 flex-1 bg-base-200 min-h-[24px]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex-1 ${entry.isDeleted ? 'bg-base-200/50' : 'bg-base-200'} rounded-lg p-3`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">{entryIcons[entry.type]}</span>
|
||||||
|
<span className={`font-semibold text-sm ${entry.isDeleted ? 'line-through' : ''}`}>
|
||||||
|
{entryLabels[entry.type] || entry.type}
|
||||||
|
</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-1 ml-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={editTimestamps[entry.id] || ''}
|
||||||
|
onChange={(e) => setEditTimestamps((prev) => ({ ...prev, [entry.id]: e.target.value }))}
|
||||||
|
className="input input-bordered input-xs w-44"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs text-success"
|
||||||
|
onClick={() => saveTimestamp(entry.id, forceOv)}
|
||||||
|
>
|
||||||
|
<Check className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs"
|
||||||
|
onClick={() => cancelEditing(entry.id)}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-base-content/60 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={forceOv}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingOverride((prev) => ({ ...prev, [entry.id]: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Force override
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${entry.isDeleted ? 'text-base-content/30' : 'text-base-content/50'} ml-1`}
|
||||||
|
>
|
||||||
|
{formatTime(entry.timestamp, 'MMM d, h:mm a')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-square btn-xs"
|
||||||
|
onClick={() => startEditingTimestamp(entry)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3 text-base-content/40" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{entry.isDeleted ? (
|
||||||
|
<button className="btn btn-ghost btn-xs" onClick={() => handleRestoreEntry(entry.id)}>
|
||||||
|
<Ban className="size-4 text-base-content/40" /> Restore
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
onClick={() => handleDeleteEntry(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entry.photoBase64 && !entry.isDeleted && (
|
||||||
|
<Image
|
||||||
|
src={entry.photoBase64}
|
||||||
|
alt="Punch"
|
||||||
|
width={96}
|
||||||
|
height={72}
|
||||||
|
unoptimized
|
||||||
|
className="mt-2 w-24 h-18 object-cover rounded-lg border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!selectedDayEntries?.entries?.length && (
|
||||||
|
<p className="text-center py-4 text-base-content/40 text-sm">No punches recorded for this day</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-4" />
|
||||||
|
|
||||||
|
{/* Manual Punch Form */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||||
|
<Plus className="size-4" /> Add Manual Punch
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{manualPunchError && (
|
||||||
|
<div className="alert alert-error mb-3 text-sm py-2">
|
||||||
|
<AlertTriangle className="size-4" /> {manualPunchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="manual-punch-type">
|
||||||
|
<span className="label-text text-xs">Type</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full select-sm"
|
||||||
|
value={manualPunchForm?.type || ''}
|
||||||
|
id="manual-punch-type"
|
||||||
|
onChange={(e) => setManualPunchForm((prev) => (prev ? { ...prev, type: e.target.value } : prev))}
|
||||||
|
>
|
||||||
|
<option value="IN">Clock In</option>
|
||||||
|
<option value="OUT">Clock Out</option>
|
||||||
|
<option value="BREAK_OUT">Break Start</option>
|
||||||
|
<option value="BREAK_IN">Break End</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="manual-punch-timestamp">
|
||||||
|
<span className="label-text text-xs">Date & Time</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="input input-bordered w-full input-sm"
|
||||||
|
id="manual-punch-timestamp"
|
||||||
|
value={manualPunchForm?.timestamp?.slice(0, 16) || ''}
|
||||||
|
onChange={(e) => setManualPunchForm((prev) => (prev ? { ...prev, timestamp: e.target.value } : prev))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-sm"
|
||||||
|
checked={manualPunchForm?.forceOverride || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setManualPunchForm((prev) => (prev ? { ...prev, forceOverride: e.target.checked } : prev))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Force override (skip state machine check)</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-full btn-sm"
|
||||||
|
onClick={handleAddManualPunch}
|
||||||
|
disabled={!manualPunchForm?.type || !manualPunchForm?.timestamp}
|
||||||
|
>
|
||||||
|
Add Punch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Company[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>('');
|
||||||
|
const [startDate, setStartDate] = useState<string>(() => format(subDays(new Date(), 13), 'yyyy-MM-dd'));
|
||||||
|
const [endDate, setEndDate] = useState<string>(() => format(new Date(), 'yyyy-MM-dd'));
|
||||||
|
const [data, setData] = useState<PayrollData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApi<Company[]>('/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<PayrollData>(
|
||||||
|
`/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 (
|
||||||
|
<div className="min-h-screen bg-base-200">
|
||||||
|
<header className="bg-base-100 border-b border-base-300 px-4 py-3 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">💰</span>
|
||||||
|
<h1 className="font-semibold text-lg">Payroll Export</h1>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin">
|
||||||
|
<button className="btn btn-outline btn-sm">
|
||||||
|
<ArrowLeft className="size-4" /> Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto p-4 space-y-4">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="card bg-base-100 shadow-sm border border-base-300 p-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{/* Hotel */}
|
||||||
|
<div className="form-control min-w-[200px]">
|
||||||
|
<label className="label" htmlFor="payroll-company">
|
||||||
|
<span className="label-text text-xs font-medium">Hotel</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="payroll-company"
|
||||||
|
value={selectedCompany}
|
||||||
|
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
|
||||||
|
className="select select-bordered select-sm"
|
||||||
|
>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<option key={c.id} value={c.id.toString()}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{companies.length === 0 && <option value="">No hotels</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date range */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="payroll-start-date">
|
||||||
|
<span className="label-text text-xs font-medium">From</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="payroll-start-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="input input-bordered input-sm w-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="payroll-end-date">
|
||||||
|
<span className="label-text text-xs font-medium">To</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="payroll-end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="input input-bordered input-sm w-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date presets */}
|
||||||
|
<div className="flex flex-wrap gap-1 self-end">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button key={p.label} className="btn btn-ghost btn-xs" onClick={() => applyPreset(p)}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate button */}
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={loadPayroll} disabled={loading || !selectedCompany}>
|
||||||
|
{loading ? <RefreshCw className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||||
|
{loading ? 'Loading...' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 alert alert-error text-sm py-2">
|
||||||
|
<AlertTriangle className="size-4" /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary + Export actions */}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* Summary strip */}
|
||||||
|
<div className="card bg-base-100 shadow-sm border border-base-300 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">Hotel</div>
|
||||||
|
<div className="font-semibold">{data.company}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">Period</div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
{data.period.startDate} → {data.period.endDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">Employees</div>
|
||||||
|
<div className="font-semibold">{data.employees.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">Total Days</div>
|
||||||
|
<div className="font-semibold">{totalStats.totalDays}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">Total Hours</div>
|
||||||
|
<div className="font-semibold text-success">{formatHours(totalStats.totalHours)}</div>
|
||||||
|
</div>
|
||||||
|
{totalStats.errorDays > 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-base-content/40 text-xs uppercase tracking-wider">⚠️ Issues</div>
|
||||||
|
<div className="font-semibold text-warning">
|
||||||
|
{totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with errors
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={copyTSV}>
|
||||||
|
{copied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
|
||||||
|
{copied ? 'Copied!' : 'Copy TSV'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={downloadTSV}>
|
||||||
|
<Download className="size-4" /> Download .tsv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue warning bar */}
|
||||||
|
{totalStats.errorDays > 0 && (
|
||||||
|
<div className="mt-3 alert alert-warning text-sm py-2">
|
||||||
|
<AlertTriangle className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with validation issues , review
|
||||||
|
the <span className="font-semibold">Notes</span> column below before exporting to payroll. Errors
|
||||||
|
include: missing clock-out, unended breaks, or consecutive duplicate entries.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Table */}
|
||||||
|
{data.employees.length > 0 ? (
|
||||||
|
<div className="card bg-base-100 shadow-sm border border-base-300 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-base-300 bg-base-200/50">
|
||||||
|
<h2 className="font-semibold text-sm">Payroll Preview</h2>
|
||||||
|
<p className="text-xs text-base-content/50 mt-0.5">
|
||||||
|
TSV columns: Employee Name | Date | Clock In | Clock Out | Break (min) | Net Hours | Notes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra table-xs w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] uppercase text-base-content/50">
|
||||||
|
<th className="w-40">Employee</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th className="text-center">In</th>
|
||||||
|
<th className="text-center">Out</th>
|
||||||
|
<th className="text-center">Break (min)</th>
|
||||||
|
<th className="text-center">Net Hours</th>
|
||||||
|
<th className="w-48">Notes / Issues</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<Fragment key={emp.id}>
|
||||||
|
{visibleDays.map((day, di) => (
|
||||||
|
<tr key={`${emp.id}-${di}`} className={day.hasErrors ? 'bg-warning/5' : ''}>
|
||||||
|
{di === 0 && (
|
||||||
|
<td className="font-medium align-top" rowSpan={visibleDays.length}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>{emp.name}</span>
|
||||||
|
{hasAnyError && <AlertTriangle className="size-3 text-warning shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="text-xs text-base-content/60">{day.date}</td>
|
||||||
|
<td className="text-center text-xs font-mono">{day.inTime || ','}</td>
|
||||||
|
<td className="text-center text-xs font-mono">{day.outTime || ','}</td>
|
||||||
|
<td className="text-center text-xs">{day.breakMinutes > 0 ? day.breakMinutes : ','}</td>
|
||||||
|
<td
|
||||||
|
className={`text-center text-sm font-semibold ${day.workedMinutes > 0 ? 'text-success' : 'text-base-content/30'}`}
|
||||||
|
>
|
||||||
|
{day.workedMinutes > 0 ? fmtDecimalHours(day.workedMinutes) : ','}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{day.hasErrors ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{day.errors.map((err, ei) => (
|
||||||
|
<span key={ei} className="badge badge-warning badge-xs font-normal">
|
||||||
|
⚠️ {err}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : day.workedMinutes > 0 ? (
|
||||||
|
<span className="badge badge-success badge-xs">✅ OK</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-base-content/20 text-xs">,</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* Totals row */}
|
||||||
|
<tr className="bg-base-200 font-semibold text-xs">
|
||||||
|
<td colSpan={2} className="text-right">
|
||||||
|
<span className="text-base-content/60 mr-2">{emp.name} , TOTAL</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">,</td>
|
||||||
|
<td className="text-center">,</td>
|
||||||
|
<td className="text-center text-warning">{formatHours(emp.totals.breakMinutes)}</td>
|
||||||
|
<td className="text-center text-success">{fmtDecimalHours(emp.totals.workedMinutes)}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card bg-base-100 shadow-sm border border-base-300 p-12 text-center text-base-content/40">
|
||||||
|
<div className="text-4xl mb-3">📋</div>
|
||||||
|
<p>No time entries found for this period</p>
|
||||||
|
<p className="text-sm mt-1">Try a different date range</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!data && !loading && (
|
||||||
|
<div className="text-center py-20 text-base-content/30">
|
||||||
|
<div className="text-5xl mb-4">💰</div>
|
||||||
|
<p className="text-lg">Select a hotel and date range, then click Preview</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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<typeof employees.$inferInsert> = {};
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -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) }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof timeEntries.$inferInsert> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'daisyui/daisyui.css';
|
||||||
@@ -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<Company | null>(null);
|
||||||
|
const [employees, setEmployees] = useState<EmployeeWithStatus[]>([]);
|
||||||
|
const [punchModal, setPunchModal] = useState<Employee | null>(null);
|
||||||
|
const [showSuccess, setShowSuccess] = useState<string | null>(null);
|
||||||
|
const [isPunching, setIsPunching] = useState(false);
|
||||||
|
const [punchingType, setPunchingType] = useState<PunchType | null>(null);
|
||||||
|
const [cameraError, setCameraError] = useState(false);
|
||||||
|
const [punchError, setPunchError] = useState<string | null>(null);
|
||||||
|
const webcamRef = useRef<Webcam>(null);
|
||||||
|
const now = useNowTime();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!companyId) return;
|
||||||
|
fetchApi<Company[]>('/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<PunchResponse>('/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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-base-200 flex flex-col">
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="toast toast-top toast-center z-50">
|
||||||
|
<div className="alert alert-success gap-3">
|
||||||
|
<span className="text-base">✅</span>
|
||||||
|
<span className="font-semibold">{showSuccess}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="bg-base-100 border-b border-base-300 px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">⏰</span>
|
||||||
|
<h1 className="font-semibold text-lg">Pulse Clock</h1>
|
||||||
|
{company && <span className="badge badge-outline ml-1">{company.name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-lg font-semibold tabular-nums" suppressHydrationWarning>
|
||||||
|
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-square btn-sm"
|
||||||
|
onClick={refreshKiosk}
|
||||||
|
aria-label="Refresh kiosk"
|
||||||
|
title="Refresh kiosk"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</button>
|
||||||
|
<Link href="/admin/manage">
|
||||||
|
<button className="btn btn-ghost btn-square btn-sm" aria-label="Back to admin" title="Back to admin">
|
||||||
|
<Home className="size-4" />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 p-4 max-w-5xl mx-auto w-full">
|
||||||
|
{employees.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-base-content/40">
|
||||||
|
<div className="text-5xl mb-4">👥</div>
|
||||||
|
<p className="text-lg">No active employees found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||||
|
{employees.map((emp) => (
|
||||||
|
<EmployeeCard
|
||||||
|
key={emp.id}
|
||||||
|
employee={{ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) }}
|
||||||
|
status={emp.status}
|
||||||
|
shiftStartUnix={emp.shiftStartTimestamp}
|
||||||
|
activeBreakStartUnix={emp.activeBreakStartTimestamp}
|
||||||
|
completedBreakSeconds={emp.completedBreakSeconds}
|
||||||
|
onClick={() =>
|
||||||
|
openPunchModal({ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) })
|
||||||
|
}
|
||||||
|
statusColor={statusColor}
|
||||||
|
statusLabel={statusLabel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{punchModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close punch modal"
|
||||||
|
className="fixed inset-0 bg-black/50 cursor-default"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isPunching) setPunchModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bg-base-100 rounded-2xl shadow-2xl p-6 w-full max-w-md relative z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isPunching) setPunchModal(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-xl mb-1">{punchModal.name}</h3>
|
||||||
|
<p className="text-sm text-base-content/60 mb-4">
|
||||||
|
Status:{' '}
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${currentModalStatus(employees, punchModal.id) === 'IN' ? 'text-success' : currentModalStatus(employees, punchModal.id) === 'BREAK' ? 'text-warning' : 'text-base-content/60'}`}
|
||||||
|
>
|
||||||
|
{statusLabel(currentModalStatus(employees, punchModal.id))}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{punchError && (
|
||||||
|
<div className="alert alert-error mb-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-2 w-full">
|
||||||
|
<span className="text-lg">❌</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-sm">Punch Rejected</div>
|
||||||
|
<div className="text-xs opacity-80 mt-0.5">{punchError}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost btn-square btn-xs text-error" onClick={() => setPunchError(null)}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPunching ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
|
<Loader2 className="size-12 animate-spin text-primary" />
|
||||||
|
<p className="text-base-content/60 text-sm">
|
||||||
|
{punchingType === 'OUT'
|
||||||
|
? 'Clocking out...'
|
||||||
|
: punchingType === 'BREAK_OUT'
|
||||||
|
? 'Starting break…'
|
||||||
|
: punchingType === 'BREAK_IN'
|
||||||
|
? 'Ending break…'
|
||||||
|
: 'Clocking in…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative bg-neutral-950 rounded-lg overflow-hidden">
|
||||||
|
{cameraError ? (
|
||||||
|
<div className="h-48 flex items-center justify-center text-base-content/40 text-sm">
|
||||||
|
Camera unavailable
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Webcam
|
||||||
|
ref={webcamRef}
|
||||||
|
screenshotFormat="image/jpeg"
|
||||||
|
videoConstraints={{ width: 320, height: 240, facingMode: 'user' }}
|
||||||
|
onUserMediaError={() => setCameraError(true)}
|
||||||
|
className="w-full scale-x-[-1]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
{currentModalStatus(employees, punchModal.id) !== 'IN' &&
|
||||||
|
currentModalStatus(employees, punchModal.id) !== 'BREAK' && (
|
||||||
|
<button className="btn btn-success" onClick={() => handleAction('IN')}>
|
||||||
|
🟢 Clock In
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(currentModalStatus(employees, punchModal.id) === 'IN' ||
|
||||||
|
currentModalStatus(employees, punchModal.id) === 'BREAK') && (
|
||||||
|
<button className="btn btn-error" onClick={() => handleAction('OUT')}>
|
||||||
|
🔴 Clock Out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{currentModalStatus(employees, punchModal.id) === 'IN' && (
|
||||||
|
<button className="btn btn-warning" onClick={() => handleAction('BREAK_OUT')}>
|
||||||
|
🟡 Start Break
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{currentModalStatus(employees, punchModal.id) === 'BREAK' && (
|
||||||
|
<button className="btn btn-warning" onClick={() => handleAction('BREAK_IN')}>
|
||||||
|
🟡 End Break
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`p-4 rounded-xl border-2 text-center transition-all hover:shadow-lg active:scale-95 ${statusColor(status)}`}
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-1">{status === 'IN' ? '🟢' : status === 'BREAK' ? '🟡' : '⚪'}</div>
|
||||||
|
<div className="font-semibold text-sm leading-tight">{employee.name}</div>
|
||||||
|
<div className="text-xs mt-1 opacity-75">{statusLabel(status)}</div>
|
||||||
|
{status === 'IN' && elapsed && (
|
||||||
|
<div className="text-xs mt-1 font-mono font-semibold opacity-90">On shift · {elapsed}</div>
|
||||||
|
)}
|
||||||
|
{status === 'BREAK' && elapsed && (
|
||||||
|
<div className="text-xs mt-1 font-mono font-semibold opacity-90">On break · {elapsed} worked</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<KioskZoomLock />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function KioskIndex() {
|
||||||
|
redirect('/admin/manage');
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||||
|
<body className="min-h-full flex flex-col">
|
||||||
|
<AuthGate>{children}</AuthGate>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<main className="min-h-screen bg-base-200 flex items-center justify-center p-6">
|
||||||
|
<section className="w-full max-w-sm bg-base-100 border border-base-300 rounded-lg shadow-xl p-6 space-y-5">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Pulse Clock</h1>
|
||||||
|
<p className="text-sm text-base-content/60">Enter the admin secret to continue.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error text-sm py-2">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Admin secret"
|
||||||
|
value={secret}
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary w-full" disabled={loading || !secret.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
Verifying
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Unlock'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<main className="min-h-screen bg-base-200 flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg" />
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/admin');
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||||
|
<span className="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-base-100 rounded-2xl shadow-2xl p-8 w-full max-w-sm space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl mb-2">🔒</div>
|
||||||
|
<h2 className="text-xl font-semibold">Authorization Required</h2>
|
||||||
|
<p className="text-base-content/60 text-sm mt-1">
|
||||||
|
Enter the admin secret to continue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error text-sm py-2">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Enter secret"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
disabled={loading || !password.trim()}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
Verifying…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Unlock'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||||
|
<span className="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthGateInner>{children}</AuthGateInner>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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') });
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type ApiResponse<T> = { data: T; success: true } | { error: string; details?: unknown; success: false };
|
||||||
|
|
||||||
|
export function ok<T>(data: T): ApiResponse<T> {
|
||||||
|
return { data, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err(error: string, details?: unknown): ApiResponse<never> {
|
||||||
|
return { error, details, success: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiResponse } from './api-response';
|
||||||
|
|
||||||
|
export async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const PunchType = z.enum(['IN', 'OUT', 'BREAK_IN', 'BREAK_OUT']);
|
||||||
|
export type PunchType = z.infer<typeof PunchType>;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
@@ -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<typeof statusAfter>;
|
||||||
|
overridden: boolean;
|
||||||
|
}
|
||||||
|
| { ok: false; status: number; error: string };
|
||||||
|
|
||||||
|
export async function createValidatedTimeEntry(input: CreateTimeEntryInput): Promise<CreateTimeEntryResult> {
|
||||||
|
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<PunchType, keyof typeof AUDIT_ACTIONS>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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<PunchType, string> = {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
+165
@@ -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).*)'],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user