From 1f19281aaa4f0c9831c6664a142deedd2d98d7df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:48:45 +0000 Subject: [PATCH 001/139] docs: complete platform design documentation Master specification plus nine derived design documents: product requirements, system architecture (C4 + ADRs), normalized database design with ER diagrams, REST API v1 reference, Android architecture and navigation, web admin console design, security architecture (STRIDE, RBAC catalog, biometrics privacy), offline-first sync strategy, and the P0-P4 development roadmap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- docs/00-master-spec.md | 239 ++++++++++ docs/01-product-requirements.md | 291 ++++++++++++ docs/02-system-architecture.md | 358 +++++++++++++++ docs/03-database-design.md | 735 +++++++++++++++++++++++++++++++ docs/04-api-design.md | 582 ++++++++++++++++++++++++ docs/05-android-architecture.md | 307 +++++++++++++ docs/06-web-admin-design.md | 297 +++++++++++++ docs/07-security-architecture.md | 306 +++++++++++++ docs/08-sync-strategy.md | 307 +++++++++++++ docs/09-roadmap.md | 259 +++++++++++ 10 files changed, 3681 insertions(+) create mode 100644 docs/00-master-spec.md create mode 100644 docs/01-product-requirements.md create mode 100644 docs/02-system-architecture.md create mode 100644 docs/03-database-design.md create mode 100644 docs/04-api-design.md create mode 100644 docs/05-android-architecture.md create mode 100644 docs/06-web-admin-design.md create mode 100644 docs/07-security-architecture.md create mode 100644 docs/08-sync-strategy.md create mode 100644 docs/09-roadmap.md diff --git a/docs/00-master-spec.md b/docs/00-master-spec.md new file mode 100644 index 0000000..6517c53 --- /dev/null +++ b/docs/00-master-spec.md @@ -0,0 +1,239 @@ +# WorkTrack — Master Specification (Source of Truth) + +> This document is the canonical reference for the WorkTrack platform. Every other design +> document, the Android codebase, the backend, and the web admin design derive from it. +> When a conflict arises between documents, this file wins; update it first. + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + +--- + +## 1. Product definition + +WorkTrack is a multi-tenant Workforce Management Platform (HRMS) covering: + +| Domain | Capabilities | +|---|---| +| Identity & Org | Multi-company (tenant), multi-branch, departments, positions, employee lifecycle, RBAC | +| Attendance | GPS + geofence punch, QR kiosk check-in, face verification, shift-aware computation, overtime, regularization | +| Shift Scheduling | Shift templates, rosters, rotations, swap requests, open-shift claiming | +| Leave | Leave types, policies, accrual engine, balances, multi-level approvals, holiday calendars | +| Payroll | Salary structures, earning/deduction components, payroll runs, payslips, statutory rule hooks | +| HR Operations | Onboarding/offboarding checklists, documents, announcements, org directory | +| Analytics | Attendance/leave/payroll KPIs, trends, AI insights (absenteeism risk, overtime anomaly, attrition signals) | +| Platform | Audit logs, notifications, offline-first sync, device binding, enterprise security | + +Target scale: 1 → 100,000+ employees per tenant; thousands of tenants. + +### 1.1 Actors and roles + +Built-in roles (extensible via custom roles with permission sets): + +- `SUPER_ADMIN` — platform operator (cross-tenant, internal only) +- `COMPANY_ADMIN` — full control of one company +- `HR_ADMIN` — HR ops, employees, leave/attendance policy, payroll input +- `PAYROLL_ADMIN` — payroll runs, payslips, salary data +- `BRANCH_MANAGER` — scoped to branch(es): rosters, approvals, team analytics +- `TEAM_LEAD` — first-level approvals, team attendance visibility +- `EMPLOYEE` — self-service: punch, leave, payslips, profile +- `AUDITOR` — read-only + audit log access +- `KIOSK` — device role for QR kiosk terminals + +Permissions are strings `resource:action` (e.g. `attendance:approve`, `payroll:run`). +Roles are permission bundles; enforcement is server-side, mirrored client-side for UX only. + +--- + +## 2. Technology stack + +| Layer | Choice | +|---|---| +| Android | Kotlin 2.x, Jetpack Compose + Material 3, MVVM + Clean Architecture, Hilt, Room, WorkManager, DataStore, Navigation-Compose, ML Kit (QR + face), Play Integrity | +| Backend | Firebase Authentication (identity), Cloud Functions (Node 20, TypeScript, Express) exposing a versioned REST API, Firestore (system of record), Cloud Tasks (payroll jobs), Pub/Sub (fan-out), BigQuery export (analytics) | +| Web Admin | React 18 + TypeScript SPA (design in `06-web-admin-design.md`; implementation is roadmap Phase 4) | +| Infra | Firebase Hosting (admin SPA), Cloud Scheduler (accruals, roster locks), Cloud Storage (documents, face templates) | + +### 2.1 Tenancy model + +- Firestore layout: `companies/{companyId}/…` sub-collections per aggregate (see §4). +- Firebase Auth custom claims: `{ cid: companyId, r: [roleCodes], b: [branchIds], eid: employeeId }`. +- Every REST route resolves tenant from the verified ID token — never from the URL alone; URL companyId must match claim. + +--- + +## 3. Architecture overview + +``` +┌────────────┐ REST v1 (OIDC bearer) ┌─────────────────────────┐ +│ Android │ ─────────────────────────▶│ Cloud Functions (API) │ +│ offline-1st │ ◀───────── sync ──────────│ Express + middleware │ +└────────────┘ │ authn → tenant → rbac │ +┌────────────┐ └───────────┬─────────────┘ +│ Web Admin │ ──────────── same API ───────────────▶│ +└────────────┘ ┌─────────▼─────────┐ + │ Firestore │ + │ (system of record)│ + └─────────┬─────────┘ + Cloud Scheduler ──▶ jobs │ triggers + Cloud Tasks ──▶ payroll ▼ + Pub/Sub → BigQuery export → dashboards/AI +``` + +Principles: + +1. **Server-authoritative writes** for anything with money/compliance impact (attendance validity, leave balances, payroll). Clients propose; the server decides. +2. **Offline-first Android**: Room is the local source of truth; an outbox queue with idempotency keys pushes mutations; a delta-cursor pull applies server state. +3. **Append-only events** where possible (attendance punches, audit logs) — no conflict resolution needed. +4. **Versioned API** (`/v1`), additive evolution, explicit deprecation windows. + +--- + +## 4. Canonical data model + +Logical model in 3NF; maps to Room tables (client) and Firestore collections (server). +IDs are ULIDs (sortable, offline-generatable). All rows carry `companyId`, `createdAt`, +`updatedAt`, `syncStatus` (client-only), soft-delete `deletedAt`. + +### 4.1 Org & identity + +- **Company**(id, name, legalName, timezone, currency, status, plan, settingsJson) +- **Branch**(id, companyId, name, code, address, lat, lng, radiusM, timezone, status) +- **Department**(id, companyId, branchId?, name, code, parentDepartmentId?) +- **Position**(id, companyId, title, code, level, departmentId?) +- **Employee**(id, companyId, employeeCode, firstName, lastName, email, phone, avatarUrl, branchId, departmentId, positionId, managerId?, employmentType[FULL_TIME|PART_TIME|CONTRACT|INTERN], joinDate, exitDate?, status[ACTIVE|ON_LEAVE|SUSPENDED|EXITED], authUid) +- **RoleAssignment**(id, companyId, employeeId, roleCode, scopeType[COMPANY|BRANCH|DEPARTMENT], scopeId?) +- **Device**(id, companyId, employeeId, platform, model, appVersion, fcmToken, integrityVerdict, boundAt, revokedAt?) + +### 4.2 Attendance & scheduling + +- **Geofence**(id, companyId, branchId, name, lat, lng, radiusM, active) +- **Shift**(id, companyId, name, code, startTime, endTime, breakMinutes, graceInMinutes, graceOutMinutes, overtimePolicyJson, isNight, active) +- **ShiftAssignment**(id, companyId, employeeId, shiftId, date, branchId, source[ROSTER|ROTATION|MANUAL|SWAP], status) +- **ShiftSwapRequest**(id, companyId, requesterId, targetEmployeeId?, assignmentId, status, decidedBy?, decidedAt?) +- **AttendancePunch**(id, companyId, employeeId, punchedAt, type[IN|OUT], method[GPS|QR|FACE|MANUAL|KIOSK], lat?, lng?, accuracyM?, geofenceId?, insideFence, deviceId, kioskId?, faceScore?, photoUrl?, note?, serverValidated, invalidReason?) — **append-only** +- **AttendanceDay**(id, companyId, employeeId, date, shiftId?, firstInAt?, lastOutAt?, workedMinutes, breakMinutes, lateMinutes, earlyOutMinutes, overtimeMinutes, status[PRESENT|ABSENT|HALF_DAY|LEAVE|HOLIDAY|WEEK_OFF|PENDING], computedAt, version) — server-computed projection +- **RegularizationRequest**(id, companyId, employeeId, date, requestedInAt?, requestedOutAt?, reason, status[PENDING|APPROVED|REJECTED|CANCELLED], approverChainJson, decidedBy?, decidedAt?) + +### 4.3 Leave + +- **LeaveType**(id, companyId, name, code, colorHex, isPaid, requiresAttachment, active) +- **LeavePolicy**(id, companyId, leaveTypeId, accrualRule[NONE|MONTHLY|YEARLY|ANNIVERSARY], accrualDays, maxBalance, maxCarryover, minNoticedays, maxConsecutiveDays, appliesTo Json) +- **LeaveBalance**(id, companyId, employeeId, leaveTypeId, periodYear, entitledDays, accruedDays, usedDays, carriedOverDays, pendingDays, version) +- **LeaveRequest**(id, companyId, employeeId, leaveTypeId, startDate, endDate, startHalf, endHalf, days, reason, attachmentUrl?, status[DRAFT|PENDING|APPROVED|REJECTED|CANCELLED], approvalChainJson, currentApproverId?, decidedAt?) +- **HolidayCalendar**(id, companyId, name, year, branchIds Json) / **Holiday**(id, calendarId, date, name, isOptional) + +### 4.4 Payroll + +- **SalaryComponent**(id, companyId, name, code, type[EARNING|DEDUCTION|EMPLOYER_COST], calc[FIXED|PERCENT_OF_BASIC|PERCENT_OF_GROSS|FORMULA], value, formula?, taxable, statutoryCode?, active) +- **SalaryStructure**(id, companyId, name, componentIds Json) +- **EmployeeSalary**(id, companyId, employeeId, structureId, basicAmount, currency, effectiveFrom, effectiveTo?, revisionReason) +- **PayrollRun**(id, companyId, periodYear, periodMonth, branchIds Json, status[DRAFT|CALCULATING|REVIEW|APPROVED|PAID|CLOSED], startedBy, approvedBy?, totalsJson, lockedAt?) +- **Payslip**(id, companyId, runId, employeeId, periodYear, periodMonth, currency, gross, totalDeductions, net, workedDays, paidLeaveDays, lopDays, overtimeMinutes, status, pdfUrl?) +- **PayslipLine**(id, payslipId, componentCode, componentName, type, amount, meta Json) + +### 4.5 Platform + +- **Announcement**(id, companyId, title, body, audienceJson, publishAt, expiresAt?, createdBy, priority) +- **EmployeeDocument**(id, companyId, employeeId, kind, name, storagePath, mimeType, sizeBytes, expiresAt?, verifiedBy?) +- **AuditLog**(id, companyId, actorId, actorRole, action, resourceType, resourceId, beforeJson?, afterJson?, ip?, userAgent?, at) — **append-only, immutable** +- **NotificationMessage**(id, companyId, employeeId, kind, title, body, dataJson, readAt?, sentAt) +- **OutboxEntry** (client-only)(id, opType, resourceType, resourceId, payloadJson, idempotencyKey, attempts, lastError?, state[PENDING|IN_FLIGHT|DONE|FAILED], queuedAt) +- **SyncCursor** (client-only)(resourceType, cursor, lastSyncedAt) + +### 4.6 Firestore mapping + +`companies/{cid}` doc + sub-collections: `branches`, `departments`, `positions`, `employees`, +`roleAssignments`, `devices`, `geofences`, `shifts`, `shiftAssignments`, `punches`, +`attendanceDays`, `regularizations`, `leaveTypes`, `leavePolicies`, `leaveBalances`, +`leaveRequests`, `holidayCalendars`, `salaryComponents`, `salaryStructures`, +`employeeSalaries`, `payrollRuns`, `payslips`, `announcements`, `documents`, `auditLogs`, +`notifications`. Composite indexes on `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)`. + +--- + +## 5. REST API v1 (summary) + +Base: `https://api.worktrack.app/v1` · Auth: `Authorization: Bearer ` · +Idempotency: `Idempotency-Key` header honored on all POSTs · Errors: RFC 7807 problem+json · +Pagination: cursor-based `?cursor&limit` · Envelope: `{ "data": …, "meta": { cursor } }`. + +| Area | Endpoints | +|---|---| +| Session | `GET /me` (profile + roles + company), `POST /devices` (bind), `DELETE /devices/{id}` | +| Org | CRUD `/branches`, `/departments`, `/positions`, `/employees`; `POST /employees/{id}/deactivate` | +| Attendance | `POST /attendance/punches` (validate + persist), `GET /attendance/punches`, `GET /attendance/days?from&to&employeeId`, `POST /attendance/regularizations`, `POST /attendance/regularizations/{id}/decide` | +| Shifts | CRUD `/shifts`; `GET/PUT /rosters?branchId&from&to`; `POST /shift-swaps`, `POST /shift-swaps/{id}/decide` | +| Leave | `GET /leave/types`, `GET /leave/balances?employeeId`, `POST /leave/requests`, `GET /leave/requests`, `POST /leave/requests/{id}/decide`, `POST /leave/requests/{id}/cancel` | +| Payroll | `GET /payroll/runs`, `POST /payroll/runs` (async calc via Cloud Tasks), `POST /payroll/runs/{id}/approve`, `GET /payslips?employeeId&year`, `GET /payslips/{id}` | +| Comms | `GET/POST /announcements`, `GET /notifications`, `POST /notifications/{id}/read` | +| Analytics | `GET /analytics/kpis?scope&period`, `GET /analytics/insights` | +| Audit | `GET /audit-logs?resourceType&from&to` | +| Sync | `POST /sync/push` (batched outbox ops), `GET /sync/pull?types&cursor` (delta) | + +QR kiosk flow: kiosk displays rotating TOTP QR (`kioskId`, 30s window, HMAC signed); +employee app scans → `POST /attendance/punches {method:QR, kioskToken}` → server verifies +signature + window + kiosk branch vs employee branch. + +--- + +## 6. Android application + +### 6.1 Module graph + +``` +app + ├── feature:auth feature:dashboard feature:attendance + ├── feature:leave feature:payslips feature:profile + │ (feature:* → core:domain, core:designsystem, core:common) + ├── core:data ──▶ core:database, core:network, core:datastore, core:domain, core:model + ├── core:sync ──▶ core:data (workers, outbox processor, scheduling) + ├── core:domain ──▶ core:model, core:common (use cases + repository contracts) + ├── core:database / core:network / core:datastore ──▶ core:model, core:common + └── core:designsystem (M3 theme + components) core:common (Result, dispatchers, time) +``` + +Build logic lives in `build-logic/` convention plugins: +`worktrack.android.application`, `worktrack.android.library`, +`worktrack.android.library.compose`, `worktrack.android.feature`, `worktrack.android.hilt`, +`worktrack.android.room`. + +### 6.2 Navigation + +Root: `AuthGraph` (Login → ForgotPassword → DeviceBinding) → `MainGraph`. +Main scaffold: bottom bar with **Dashboard**, **Attendance**, **Leave**, **Profile**; +nested destinations: attendance history, punch flow (GPS/QR), leave apply/detail, +approvals inbox (role-gated), payslip list/detail, announcements, settings. +Deep links: `worktrack://leave/requests/{id}`, `worktrack://payslips/{id}`, `worktrack://approvals`. + +### 6.3 Offline & sync (client contract) + +1. All reads come from Room (`Flow`-based DAOs → repositories → use cases → UI state). +2. Mutations write Room optimistically (+`syncStatus=PENDING`) and enqueue an `OutboxEntry` with a ULID `idempotencyKey`. +3. `SyncWorker` (WorkManager, network-constrained, exponential backoff, unique work) drains the outbox FIFO-per-resource, then delta-pulls per resource cursor. +4. Server responses reconcile local rows (`syncStatus=SYNCED`, server fields win). +5. Punches are append-only: no update/delete ops exist client-side. +6. Conflict policy: server-authoritative; rejected ops surface as actionable notifications, never silent data loss. + +--- + +## 7. Security requirements (summary) + +- Firebase Auth + short-lived ID tokens; refresh handled by SDK; custom claims for tenant/RBAC. +- Server middleware chain: verify token → load tenant context → RBAC permission check → handler; deny-by-default. +- Firestore security rules: **no direct client access** to server-authoritative collections (all writes via API); rules act as second line of defense. +- Device binding + Play Integrity verdict required for punch endpoints; mock-location detection on-device (`isMock`) + server plausibility checks (speed-of-travel). +- Data: TLS 1.2+, at-rest encryption (Google-managed), tokens in EncryptedSharedPreferences/Keystore, no PII in logs, structured audit log for every privileged mutation. +- Face templates: stored as embeddings (not photos) in Cloud Storage with CMEK option; verification threshold server-tunable; raw capture deleted after embedding. +- Compliance posture: GDPR (DSR endpoints, retention policies), SOC 2 controls mapped in `07-security-architecture.md`. + +--- + +## 8. Delivery phases + +- **P0 (this repo, implemented)**: Android foundation — build-logic, core modules (common/model/database/network/datastore/domain/data/sync/designsystem), features (auth, dashboard, attendance, leave, payslips, profile), backend API core (auth/tenant/RBAC middleware, attendance punch + validation, leave requests + decisions, sync push/pull, payslip read), Firestore rules, full design docs. +- **P1**: Shift rosters UI, regularization, approvals inbox, face verification, kiosk app mode. +- **P2**: Payroll calculation engine + runs UI, statutory packs, document vault. +- **P3**: Web Admin SPA, analytics dashboards, BigQuery pipeline. +- **P4**: AI insights, attrition/absence prediction, anomaly detection, open APIs + webhooks. + +Details in `09-roadmap.md`. diff --git a/docs/01-product-requirements.md b/docs/01-product-requirements.md new file mode 100644 index 0000000..8f96ca4 --- /dev/null +++ b/docs/01-product-requirements.md @@ -0,0 +1,291 @@ +# WorkTrack — Product Requirements Document + +Version: 1.0 · Status: Approved · Owners: Product · Derives from: `00-master-spec.md` + +**Purpose.** This document translates the master specification into testable product requirements for the WorkTrack multi-tenant Workforce Management Platform. It defines the vision, target segments, personas, functional requirements per domain (with priority and acceptance criteria), the enterprise-hardening additions made beyond the original brief, non-functional requirements, and explicit scope boundaries. Where this document and `00-master-spec.md` diverge, the master spec wins. + +> **Priority key** — `P0` = must ship in the foundation release (maps to delivery Phase P0/P1), `P1` = required for enterprise sales readiness (Phases P2–P3), `P2` = differentiator (Phase P4). Requirement priority (P0/P1/P2) is orthogonal to delivery phase numbering (P0–P4 in `09-roadmap.md`); the phase column in each table states when the requirement is scheduled to land. + +--- + +## 1. Vision + +WorkTrack is the operational system of record for a distributed workforce: every punch, shift, leave day, and payslip flows through one auditable, offline-tolerant platform. It replaces the fragmented stack of biometric terminals, spreadsheets, and disconnected payroll tools with a single tenant-isolated platform that works for a 5-person shop and a 100,000-employee enterprise on the same codebase and the same API. + +Product pillars: + +1. **Truth over convenience** — server-authoritative computation for anything with money or compliance impact (attendance validity, leave balances, payroll). Clients propose; the server decides. +2. **Field-first** — the Android app is offline-first; a warehouse worker with no signal can punch, apply for leave, and read payslips, and the outbox reconciles later with zero silent data loss. +3. **Enterprise-honest** — audit immutability, RBAC, data residency, and DSR support are foundation features, not retrofits. +4. **One API** — Android, Web Admin, and third-party integrations consume the same versioned REST API (`/v1`); there is no privileged back channel. + +## 2. Target segments + +| Segment | Size | Buying trigger | Critical capabilities | +|---|---|---|---| +| SMB | 1–200 employees | Replace paper registers / WhatsApp attendance | GPS punch, simple leave, payslip PDF, single branch, self-serve onboarding | +| Mid-market | 200–5,000 | Multi-branch consistency, payroll input accuracy | Multi-branch geofences, shift rosters, approval chains, regularization, holiday calendars | +| Enterprise | 5,000–100,000+ | Compliance, audit, integration with ERP/IdP | RBAC with scoped roles, kiosk mode, statutory payroll hooks, audit export, BigQuery analytics, open API/webhooks, SSO/SCIM (future) | +| Platform operator (internal) | — | Operate thousands of tenants | `SUPER_ADMIN` tooling, per-tenant cost controls, plan management | + +Target scale (from master spec §1): 1 → 100,000+ employees per tenant; thousands of tenants. + +## 3. Personas (mapped to spec roles) + +| Persona | Role code(s) | Primary surface | Top jobs-to-be-done | +|---|---|---|---| +| Platform operator (internal SRE/support) | `SUPER_ADMIN` | Internal tooling / Web Admin | Provision tenants, investigate incidents cross-tenant, enforce plans | +| Company owner / COO | `COMPANY_ADMIN` | Web Admin | Configure company, branches, roles; see company-wide KPIs | +| HR manager | `HR_ADMIN` | Web Admin | Employee lifecycle, leave/attendance policy, regularization decisions, announcements | +| Payroll specialist | `PAYROLL_ADMIN` | Web Admin | Salary structures, payroll runs, payslip publication, statutory outputs | +| Branch/site manager | `BRANCH_MANAGER` | Android + Web Admin | Branch rosters, branch approvals, team attendance analytics | +| Shift supervisor | `TEAM_LEAD` | Android | First-level approvals (leave, regularization, swaps), team attendance visibility | +| Frontline employee | `EMPLOYEE` | Android | Punch in/out, view schedule, apply leave, read payslips, update profile | +| Internal/external auditor | `AUDITOR` | Web Admin | Read-only review, audit-log search and export | +| Kiosk terminal | `KIOSK` | Kiosk app mode | Display rotating TOTP QR for check-in; no human user | + +All roles are permission bundles over `resource:action` strings (e.g. `attendance:approve`, `payroll:run`); custom roles are composable from the same permission set. Enforcement is server-side; client-side mirroring is UX only. + +### 3.1 Key user journeys + +| # | Journey | Persona(s) | Path | Governing FRs | +|---|---|---|---|---| +| J1 | Morning punch-in, no connectivity | EMPLOYEE | Open app → Attendance → punch IN (GPS captured, stored in Room + outbox) → later sync validates geofence server-side | FR-ATT-001/003, FR-PLT-002 | +| J2 | Kiosk check-in at a shared site | EMPLOYEE + KIOSK | Kiosk shows rotating QR → employee scans in app → punch submitted with `kioskToken` → server verifies window/branch | FR-ATT-004 | +| J3 | Fix a missed punch-out | EMPLOYEE → TEAM_LEAD → HR_ADMIN | Attendance history shows PENDING day → raise RegularizationRequest → chain approves → AttendanceDay recomputed | FR-ATT-007, FR-LVE-003 pattern | +| J4 | Apply for leave with half-days | EMPLOYEE → approvers | Leave → balances → apply (startHalf/endHalf) → chain decides → balance moves pending→used → notification | FR-LVE-002/003/004 | +| J5 | Publish next month's roster | BRANCH_MANAGER | Rosters for branch → assign/rotate → PUT batch → employees notified; locked at T-N days | FR-SHF-002/003/006 | +| J6 | Run monthly payroll | PAYROLL_ADMIN → COMPANY_ADMIN | Create run → async calc (Cloud Tasks) → review exceptions → approve (SoD) → payslips + PDFs published | FR-PAY-002/003/004/006 | +| J7 | Investigate a suspicious punch pattern | HR_ADMIN / AUDITOR | Flagged punches (`invalidReason`) → audit log for the employee/device → device revocation if warranted | FR-ATT-005, FR-PLT-001, FR-ORG-005 | +| J8 | Offboard an employee | HR_ADMIN | Checklist completes → `POST /employees/{id}/deactivate` → claims cleared, devices unbound, roster future-cleared, history retained | FR-ORG-003, FR-HRO-004 | + +--- + +## 4. Functional requirements + +Conventions: requirement IDs are `FR--NNN`. Acceptance criteria (AC) are the minimum verifiable conditions; they assume the API contracts, entities, and error model of `00-master-spec.md` §4–§5. + +### 4.1 Identity & Org (FR-ORG) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ORG-001 | Tenant isolation: every record belongs to exactly one Company; no API call can read or write another tenant's data. | P0 | P0 | Token claim `cid` must match URL `companyId`; mismatch returns RFC 7807 `403`; verified by cross-tenant test suite. | +| FR-ORG-002 | Org structure: CRUD for Branch, Department (hierarchical via `parentDepartmentId`), Position via `/branches`, `/departments`, `/positions`. | P0 | P0 | Admin can create branch with geo (lat/lng/radiusM) and timezone; department tree renders without cycles (server rejects cyclic parent). | +| FR-ORG-003 | Employee lifecycle: create, update, deactivate (`POST /employees/{id}/deactivate`); statuses ACTIVE, ON_LEAVE, SUSPENDED, EXITED. | P0 | P0 | Deactivation revokes auth (custom claims cleared ≤ 60 s), unbinds devices, removes from future rosters; historical data retained. | +| FR-ORG-004 | RBAC: built-in roles per spec §1.1 plus custom roles as permission bundles; RoleAssignment scoped COMPANY, BRANCH, or DEPARTMENT. | P0 | P0 | A `BRANCH_MANAGER` scoped to branch B1 receives `403` on branch B2 resources; permission checks are deny-by-default. | +| FR-ORG-005 | Device binding: `POST /devices` binds one device per employee (configurable N); `DELETE /devices/{id}` revokes. | P0 | P0 | Punch from an unbound or revoked device is rejected with a machine-readable problem type; Device row stores `integrityVerdict`, `boundAt`, `revokedAt`. | +| FR-ORG-006 | Session bootstrap: `GET /me` returns profile + roles + company in one call. | P0 | P0 | Cold app start needs exactly one API call to render the authenticated shell. | +| FR-ORG-007 | Manager chain: `Employee.managerId` defines the reporting line used as default approval chain seed. | P0 | P0 | Changing a manager re-routes only future approvals; in-flight chains are unaffected. | +| FR-ORG-008 | SSO (OIDC/SAML) and SCIM provisioning for enterprise IdPs. | P2 | P4 | Employee created in IdP appears in WorkTrack ≤ 5 min; deprovisioning revokes access ≤ 5 min. | +| FR-ORG-009 | Custom roles: admins compose roles from `resource:action` permission strings; built-in roles are immutable templates. | P1 | P3 | Custom role creation requires `roles:manage`; deleting a role in use is blocked until reassignment; every role change is audit-logged with before/after permission sets. | +| FR-ORG-010 | Bulk import: CSV import for employees, departments, and shift assignments with dry-run validation. | P1 | P3 | Dry run reports per-row errors without writing; committed import is idempotent on `employeeCode`; import summary is audit-logged. | + +### 4.2 Attendance (FR-ATT) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ATT-001 | GPS punch: `POST /attendance/punches` with type IN/OUT, method GPS; server validates geofence containment and stores `insideFence`. | P0 | P0 | Punch inside fence → `serverValidated=true`; outside fence → persisted append-only with `insideFence=false` and `invalidReason` set; employee sees the outcome. | +| FR-ATT-002 | Punches are append-only; no client update/delete operations exist. | P0 | P0 | API exposes no PUT/DELETE on punches; corrections happen only via RegularizationRequest. | +| FR-ATT-003 | Offline punch: punch recorded in Room with outbox entry when offline; synced with original `punchedAt` and idempotency key. | P0 | P0 | Airplane-mode punch appears on server after reconnect exactly once (idempotent retry); `punchedAt` reflects capture time, not sync time. | +| FR-ATT-004 | QR kiosk check-in: kiosk (role `KIOSK`) displays rotating TOTP QR (30 s window, HMAC signed, carries `kioskId`); employee app scans and submits `{method:QR, kioskToken}`. | P0 | P1 | Server verifies signature + time window + kiosk branch vs employee branch; replayed or expired token rejected; clock-skew tolerance ±1 window. | +| FR-ATT-005 | Anti-spoofing: device binding + Play Integrity verdict required on punch endpoints; on-device mock-location flag (`isMock`) plus server speed-of-travel plausibility check. | P0 | P0 | Punch with failed integrity verdict or implausible travel (> configurable km/h between consecutive punches) is flagged `serverValidated=false` with `invalidReason`; surfaced to `HR_ADMIN`. | +| FR-ATT-006 | AttendanceDay computation: server-computed projection per employee/date (firstInAt, lastOutAt, workedMinutes, lateMinutes, earlyOutMinutes, overtimeMinutes, status) shift-aware including night shifts (`isNight`). | P0 | P0 | Recompute is deterministic and idempotent (`version` increments); grace windows (`graceInMinutes`/`graceOutMinutes`) applied; night shift spanning midnight attributes to the shift's start date. | +| FR-ATT-007 | Regularization: employee raises RegularizationRequest (requested in/out, reason); multi-level decision via `POST /attendance/regularizations/{id}/decide`; approval triggers AttendanceDay recompute. | P0 | P1 | Status transitions limited to PENDING→APPROVED/REJECTED/CANCELLED; approver chain honored (`approverChainJson`); recompute completes ≤ 60 s after approval. | +| FR-ATT-008 | Attendance history: `GET /attendance/days?from&to&employeeId` and `GET /attendance/punches`, RBAC-scoped (self, team, branch, company). | P0 | P0 | `EMPLOYEE` sees only self; `TEAM_LEAD` sees direct reports; cursor pagination; range capped server-side (≤ 92 days per query). | +| FR-ATT-009 | Face verification punch: embedding match against stored template, threshold server-tunable; raw capture deleted after embedding. | P1 | P1 | `faceScore` persisted on the punch; below-threshold match falls back per policy (reject or flag); no raw photo retained beyond embedding pipeline. | +| FR-ATT-010 | Overtime: computed from `overtimePolicyJson` on Shift; feeds `overtimeMinutes` into AttendanceDay and payroll. | P1 | P2 | OT below policy threshold is 0; OT rounding rule applied consistently; payslip OT equals sum of AttendanceDay OT for the period. | +| FR-ATT-011 | Day-status completeness: AttendanceDay status covers PRESENT, ABSENT, HALF_DAY, LEAVE, HOLIDAY, WEEK_OFF, PENDING; WEEK_OFF derives from roster gaps per policy, LEAVE from approved LeaveRequests, HOLIDAY from the branch calendar. | P0 | P1 | For any employee/date exactly one status is computed; precedence order (HOLIDAY > LEAVE > WEEK_OFF > punch-derived) is documented and test-covered; PENDING only while the day is incomplete. | +| FR-ATT-012 | Punch context: optional `note` and `photoUrl` on a punch (e.g. off-site client visit); photo capture policy per company. | P1 | P1 | Note length capped; photo uploaded via signed URL and linked before punch submission completes; photos excluded from face-verification pipeline. | + +### 4.3 Shift Scheduling (FR-SHF) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-SHF-001 | Shift templates: CRUD `/shifts` (start/end, breakMinutes, grace windows, overtime policy, `isNight`). | P0 | P0 | Overlap and validity checks server-side; deactivating a shift does not alter historical ShiftAssignments. | +| FR-SHF-002 | Rosters: `GET/PUT /rosters?branchId&from&to` assigns shifts per employee per date (ShiftAssignment, source ROSTER/ROTATION/MANUAL/SWAP). | P0 | P1 | Bulk PUT is transactional per batch and idempotent; one active assignment per employee per date enforced; conflicts return per-item errors, not batch failure. | +| FR-SHF-003 | Rotation patterns: recurring patterns generate assignments ahead of time via scheduled jobs. | P1 | P1 | Generation window configurable (e.g. 28 days ahead); regeneration never overwrites MANUAL or SWAP assignments. | +| FR-SHF-004 | Shift swaps: `POST /shift-swaps` (targeted or open), `POST /shift-swaps/{id}/decide` by approver. | P1 | P1 | Approved swap atomically re-points both ShiftAssignments with source=SWAP; declined/expired swaps leave the roster untouched. | +| FR-SHF-005 | Open-shift claiming: unassigned roster slots are claimable by eligible employees, subject to approval. | P1 | P1 | Eligibility = same branch + position match + no conflicting assignment; first approved claim wins; losers are notified. | +| FR-SHF-006 | Roster locks: Cloud Scheduler locks rosters N days before the period; later changes require elevated permission. | P1 | P1 | Post-lock edits require `roster:override` permission and produce an AuditLog entry. | + +### 4.4 Leave (FR-LVE) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-LVE-001 | Leave catalog: LeaveType (paid flag, attachment requirement) + LeavePolicy (accrual NONE/MONTHLY/YEARLY/ANNIVERSARY, maxBalance, maxCarryover, minNoticedays, maxConsecutiveDays, appliesTo). | P0 | P0 | Policy resolution is deterministic for any employee via `appliesTo` matching; exactly one policy applies per type per employee. | +| FR-LVE-002 | Apply for leave: `POST /leave/requests` with half-day support (startHalf/endHalf); computed `days` excludes holidays and week-offs. | P0 | P0 | Overlapping-request rejection; insufficient balance rejection (unless policy allows negative); attachment enforced when `requiresAttachment`. | +| FR-LVE-003 | Multi-level approval: `approvalChainJson` derived from manager chain and policy; `POST /leave/requests/{id}/decide` advances the chain; `.../cancel` by requester. | P0 | P0 | Only `currentApproverId` (or scoped admin) can decide; each hop notifies the next approver; full decision history retained. | +| FR-LVE-004 | Balances: LeaveBalance per employee/type/periodYear (entitled, accrued, used, carriedOver, pending) maintained server-side with optimistic `version`. | P0 | P0 | Applying moves days to `pendingDays`; approval moves pending→used; rejection/cancellation returns pending; balances never computed client-side. | +| FR-LVE-005 | Accrual engine: Cloud Scheduler applies accrual rules; year-end carryover honors `maxCarryover`. | P0 | P1 | Accrual job is idempotent per (employee, type, period); re-runs produce no double credit; audit entry per adjustment batch. | +| FR-LVE-006 | Holiday calendars: HolidayCalendar per year with branch mapping (`branchIds`); Holiday supports `isOptional`. | P0 | P1 | Attendance status HOLIDAY derived from the employee's branch calendar; leave-day computation skips holidays; optional-holiday elections capped per policy. | +| FR-LVE-007 | Leave visibility: `GET /leave/requests` and `GET /leave/balances?employeeId` RBAC-scoped; approvers see team calendars. | P0 | P0 | `TEAM_LEAD` sees direct reports' approved leave in schedule views; employees see own balances in ≤ 1 API call. | +| FR-LVE-008 | Optional-holiday election: employees elect from `isOptional` holidays up to a per-policy cap; elections feed attendance status. | P1 | P1 | Election window enforced; cap enforced per periodYear; elected day computes as HOLIDAY for that employee only. | +| FR-LVE-009 | Offline leave application: leave requests composed offline enter the outbox and sync with balance validation deferred to the server. | P0 | P0 | Offline-created request shows `syncStatus=PENDING`; server rejection (e.g. insufficient balance) surfaces as an actionable notification, and the request moves to a correctable failed state — never silently dropped. | + +### 4.5 Payroll (FR-PAY) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-PAY-001 | Salary configuration: SalaryComponent (EARNING/DEDUCTION/EMPLOYER_COST; FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA), SalaryStructure, EmployeeSalary with effective dating. | P0 | P2 | Overlapping EmployeeSalary effective ranges rejected; formula components validated at save time; every revision stores `revisionReason`. | +| FR-PAY-002 | Payroll run: `POST /payroll/runs` starts async calculation via Cloud Tasks; states DRAFT→CALCULATING→REVIEW→APPROVED→PAID→CLOSED. | P0 | P2 | Run over 100k employees completes ≤ 30 min; progress observable; failed employee calculations quarantined without failing the run; recalculation allowed until APPROVED. | +| FR-PAY-003 | Attendance/leave integration: workedDays, paidLeaveDays, lopDays, overtimeMinutes on Payslip derive from AttendanceDay and LeaveRequest projections for the period. | P0 | P2 | Payslip figures reconcile exactly with attendance data at run time; period locked (`lockedAt`) after approval — later regularizations route to the next run as arrears. | +| FR-PAY-004 | Payslips: PayslipLine per component; PDF rendered to Cloud Storage (`pdfUrl`); employee access via `GET /payslips?employeeId&year` and `GET /payslips/{id}`. | P0 | P2 | Employee sees only own payslips; payslip visible only after run APPROVED; PDF downloadable offline once cached. | +| FR-PAY-005 | Statutory rule hooks: SalaryComponent `statutoryCode` binds to pluggable per-jurisdiction statutory packs (e.g. PF/ESI/TDS-style rules) evaluated during calculation. | P1 | P2 | Statutory pack versioned; run records the pack version used; changing a pack never mutates historical payslips. | +| FR-PAY-006 | Approval & segregation of duties: `POST /payroll/runs/{id}/approve` requires `payroll:approve`; initiator (`startedBy`) cannot self-approve when SoD is enabled. | P0 | P2 | Approval writes `approvedBy` + AuditLog with totals snapshot (`totalsJson`); CLOSED runs are immutable. | +| FR-PAY-007 | Bank/export outputs: approved runs export payment register (CSV/SEPA-style) and GL summary. | P1 | P2 | Export totals equal run `totalsJson`; exports are audit-logged. | +| FR-PAY-008 | Arrears handling: attendance corrections approved after a run is locked (`lockedAt`) are carried as arrears lines into the next run, never retro-mutating issued payslips. | P0 | P2 | Post-lock regularization creates an arrears delta traceable to the source date; next run's payslip shows the arrears PayslipLine with `meta` referencing the origin period; issued payslips are immutable. | + +### 4.6 HR Operations (FR-HRO) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-HRO-001 | Announcements: `GET/POST /announcements` with audience targeting (`audienceJson`), scheduling (`publishAt`), expiry, priority. | P0 | P0 | Only matching audience receives the announcement + push; expired items disappear from feeds; priority affects ordering and notification channel. | +| FR-HRO-002 | Notifications: `GET /notifications`, `POST /notifications/{id}/read`; FCM push for approvals, decisions, roster changes, payslip publication. | P0 | P0 | Every state transition that requires human action generates a NotificationMessage; read state syncs across devices. | +| FR-HRO-003 | Document vault: EmployeeDocument (kind, storagePath, expiry, verifiedBy) with signed-URL access. | P1 | P2 | Upload capped by type/size; expiring documents (visas, certifications) trigger reminders at T-30/T-7; access is RBAC-scoped and audit-logged. | +| FR-HRO-004 | Onboarding/offboarding checklists: templated task lists per position/branch tracked to completion. | P1 | P2 | Offboarding completion is a precondition for EXITED status; each task records completer + timestamp. | +| FR-HRO-005 | Org directory: searchable directory (name, position, department, branch) respecting field-level privacy settings. | P1 | P3 | Phone/email visibility configurable per company; search p95 < 500 ms at 100k employees. | + +### 4.7 Analytics (FR-ANA) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ANA-001 | KPIs: `GET /analytics/kpis?scope&period` — headcount, attendance %, late %, absenteeism, OT hours, leave utilization, payroll cost; scope respects RBAC. | P0 | P3 | KPI freshness ≤ 24 h (BigQuery-backed) or real-time where Firestore counters exist; `BRANCH_MANAGER` scope limited to assigned branches. | +| FR-ANA-002 | BigQuery pipeline: Firestore → BigQuery export feeds dashboards and AI; analytics queries never scan Firestore at company scale. | P0 | P3 | No analytics endpoint issues unbounded Firestore collection scans; BigQuery datasets are tenant-partitioned. | +| FR-ANA-003 | AI insights: `GET /analytics/insights` — absenteeism risk, overtime anomaly, attrition signals, with model explanation and confidence. | P2 | P4 | Insights are advisory and human-reviewable; per-tenant opt-out; no automated adverse action is taken from a model output. | +| FR-ANA-004 | Exports: KPI and audit exports to CSV; scheduled email digests for admins. | P1 | P3 | Export generation is async with notification on completion; exports are audit-logged. | + +### 4.8 Platform (FR-PLT) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-PLT-001 | Audit log: append-only, immutable AuditLog for every privileged mutation (actor, action, resource, before/after, ip, userAgent); queryable via `GET /audit-logs?resourceType&from&to`. | P0 | P0 | No API mutates or deletes audit entries; `AUDITOR` role can read all; retention configurable ≥ 7 years for payroll-affecting actions. | +| FR-PLT-002 | Offline-first sync: `POST /sync/push` (batched outbox ops with idempotency keys), `GET /sync/pull?types&cursor` (delta). | P0 | P0 | 72 h fully-offline operation for employee flows; rejected ops surface as actionable notifications — never silent loss; push is idempotent under retry. | +| FR-PLT-003 | Idempotency: `Idempotency-Key` header honored on all POSTs; duplicate submission returns the original result. | P0 | P0 | Same key + same payload → identical response, no double effect; same key + different payload → `409` problem. | +| FR-PLT-004 | API platform: versioned `/v1`, additive evolution, explicit deprecation windows; RFC 7807 errors; cursor pagination with `{data, meta:{cursor}}` envelope. | P0 | P0 | Breaking change requires new version; deprecation announced ≥ 180 days ahead; every list endpoint paginates. | +| FR-PLT-005 | Webhooks & open API: tenant-configurable webhooks (HMAC-signed, retried) for employee/attendance/leave/payroll events; public OpenAPI spec + API keys for server-to-server integrations. | P2 | P4 | Webhook delivery ≥ 3 retries with backoff + DLQ; secrets rotatable; API-key scopes reuse `resource:action` permissions. | +| FR-PLT-006 | Rate limiting & abuse protection: per-token and per-tenant limits with `429` + `Retry-After`. | P0 | P0 | Sync endpoints have higher burst allowance; limits never drop punch data (client outbox retries); limits documented per endpoint class. | +| FR-PLT-007 | GDPR/DSR: data subject access export and erasure endpoints; retention policies per data class; erasure preserves financial/statutory records via pseudonymization. | P1 | P3 | DSR export delivered ≤ 30 days (target ≤ 72 h automated); erasure pseudonymizes PII while retaining payroll/audit integrity; every DSR is audit-logged. | +| FR-PLT-008 | Data residency: tenant data pinned to a declared region (Firestore/Storage/BigQuery location) at tenant creation. | P1 | P3 | Region immutable post-creation (migration = support process); backups and exports remain in-region. | + +--- + +## 5. Gaps identified in the original brief & added enterprise features + +The original brief ("attendance + payroll app with GPS punch") omitted capabilities that are mandatory for mid-market and enterprise deployment. This section records each gap, the resolution now embedded in the spec and requirements above, and where it lands. + +| # | Gap in original brief | Resolution in WorkTrack | Where | +|---|---|---|---| +| 1 | No correction path for missed/invalid punches | Attendance **regularization** workflow with multi-level approval and recompute (RegularizationRequest) | FR-ATT-007, Phase P1 | +| 2 | Single-approver assumption | **Approval chains** (`approvalChainJson`) for leave, regularization, swaps; chain derived from manager line + policy | FR-LVE-003, FR-ATT-007 | +| 3 | No holiday awareness | **Holiday calendars** per branch/year with optional holidays; drives attendance status and leave-day math | FR-LVE-006 | +| 4 | Punch spoofing unaddressed | **Device binding + Play Integrity**, mock-location detection, server speed-of-travel plausibility | FR-ORG-005, FR-ATT-005 | +| 5 | No shared-terminal story | **Kiosk TOTP QR** flow: `KIOSK` role, rotating HMAC-signed 30 s tokens, branch cross-check | FR-ATT-004 | +| 6 | Payroll treated as simple arithmetic | **Statutory rule hooks** (`statutoryCode` + versioned jurisdiction packs), segregation of duties, arrears routing | FR-PAY-005/006, FR-PAY-003 | +| 7 | No tamper-evidence for HR/payroll actions | **Audit immutability**: append-only AuditLog with before/after snapshots on every privileged mutation | FR-PLT-001 | +| 8 | No regional compliance posture | **Data residency** pinning per tenant | FR-PLT-008 | +| 9 | GDPR ignored | **DSR endpoints** (export/erasure with pseudonymization of statutory records) | FR-PLT-007 | +| 10 | Closed system | **Webhooks + open API** (OpenAPI, HMAC-signed events, scoped API keys) | FR-PLT-005, Phase P4 | +| 11 | Password-only auth for enterprises | **SSO (OIDC/SAML) + SCIM** provisioning (future) | FR-ORG-008, Phase P4 | +| 12 | No abuse controls | **Rate limiting** per token/tenant with sync-friendly semantics | FR-PLT-006 | +| 13 | Implicit single-timezone assumption | **Multi-timezone handling**: Company and Branch carry IANA timezones; night shifts and day attribution are shift-timezone-aware; all storage in UTC instants + local date keys | FR-ATT-006, NFR-I18N | +| 14 | No accessibility commitment | **WCAG 2.1 AA** target across Android (TalkBack) and Web Admin | NFR-ACC | +| 15 | English-only assumption | **Localization incl. RTL** (externalized strings, ICU plurals, locale-aware dates/numbers/currency) | NFR-I18N | + +--- + +## 6. Non-functional requirements + +| ID | Category | Requirement | +|---|---|---| +| NFR-AVL-001 | Availability | API availability SLO **99.9%** monthly (measured at the load balancer, excluding client networks); punch write path targets 99.95%. Error budget policy gates risky releases. | +| NFR-LAT-001 | Latency | p95 budgets: `POST /attendance/punches` ≤ 400 ms; `GET /me` ≤ 300 ms; list endpoints ≤ 600 ms; `POST /sync/push` (50-op batch) ≤ 1.5 s; `GET /sync/pull` page ≤ 800 ms. Measured server-side per region. | +| NFR-OFF-001 | Offline | Android supports **≥ 72 h fully offline** for employee flows (punch, leave apply, payslip read of cached data); outbox capacity ≥ 5,000 ops; sync catch-up after 72 h offline completes ≤ 5 min on 4G. | +| NFR-SCL-001 | Scale | 100,000+ employees per tenant; thousands of tenants; ≥ 500 punch writes/s sustained per tenant at shift boundaries; payroll run for 100k employees ≤ 30 min; roster generation 100k × 28 days ≤ 15 min. | +| NFR-SEC-001 | Security | Per master spec §7: Firebase Auth short-lived tokens + custom claims; deny-by-default middleware chain; no direct client Firestore access to server-authoritative collections; TLS 1.2+; at-rest encryption; tokens in EncryptedSharedPreferences/Keystore; no PII in logs; face data stored as embeddings only, raw capture deleted; CMEK option for face templates. | +| NFR-CMP-001 | Compliance | GDPR (DSR, retention), SOC 2 control mapping per `07-security-architecture.md`; payroll-affecting audit retention ≥ 7 years; statutory pack versioning for payroll reproducibility. | +| NFR-ACC-001 | Accessibility | **WCAG 2.1 AA**: full TalkBack/keyboard navigation, ≥ 4.5:1 contrast, touch targets ≥ 48 dp, no information conveyed by color alone; Web Admin passes axe-core CI gate with zero critical violations. | +| NFR-I18N-001 | Localization | All strings externalized; ICU plural/gender support; **RTL layouts** first-class (Arabic/Hebrew/Farsi); locale-aware date/number/currency formatting; per-company currency and per-branch IANA timezone; DST-safe attendance math. | +| NFR-OBS-001 | Observability | Structured logs with trace IDs (no PII), RED metrics per endpoint, alerting on SLO burn rate; every async job (accruals, payroll, roster) emits success/failure metrics and is idempotently re-runnable. | +| NFR-CST-001 | Cost | Per-tenant cost attribution (reads/writes/storage/egress) exportable; Firestore read amplification bounded by projections (AttendanceDay) and BigQuery offload for analytics. | + +## 7. Out of scope (v1 platform) + +- Time-clock **hardware** manufacturing or on-prem biometric terminal integrations (kiosk mode on standard Android tablets covers shared terminals). +- **Tax filing/remittance** to authorities — WorkTrack computes via statutory packs and exports registers; filing is the customer's or partner's responsibility. +- **Benefits administration**, recruitment/ATS, performance management, LMS. +- **iOS app** (API is client-agnostic; iOS is a candidate after Phase P4). +- **Payments execution** (bank integration beyond export files). +- On-prem/self-hosted deployment; WorkTrack is cloud-only on the Firebase/GCP stack. +- Real-time chat/messaging (announcements + notifications only). + +## 8. Assumptions + +1. Every employee-facing user has an Android device (personal or company-issued) or access to a kiosk tablet; Web Admin covers desk personas. +2. Firebase Authentication is the sole identity provider until SSO/SCIM (FR-ORG-008) ships; email/phone uniqueness is per tenant. +3. Tenants accept Google-managed encryption at rest; CMEK is offered for face-template storage only in v1. +4. Statutory packs are developed per launch jurisdiction; a tenant in an unsupported jurisdiction runs payroll with generic components and disclaims statutory accuracy. +5. Firestore, Cloud Functions, Cloud Tasks, Pub/Sub, Cloud Scheduler, BigQuery, and Cloud Storage remain the platform stack (see ADR-001, ADR-008 in `02-system-architecture.md`); multi-cloud portability is a non-goal. +6. Clock integrity: server time is authoritative for validation windows (kiosk TOTP, token expiry); client `punchedAt` is trusted only within configured skew bounds and flagged otherwise. +7. Phase numbering and P0 scope follow `00-master-spec.md` §8 and `09-roadmap.md`; this PRD does not reorder phases. + +## 9. Success metrics + +| Metric | Definition | Target | +|---|---|---| +| Punch success rate | Punches accepted as `serverValidated=true` / total punch attempts (excluding legitimate policy rejections) | ≥ 99% | +| Sync integrity | Outbox ops resolved (DONE or user-actioned FAILED) without support intervention | 100% — silent loss is a sev-1 | +| Regularization resolution time | Median PENDING→decided for RegularizationRequest | ≤ 24 h | +| Leave decision time | Median PENDING→decided for LeaveRequest | ≤ 48 h | +| Payroll accuracy | Payslips requiring post-approval correction per run | ≤ 0.5% | +| Payroll run duration | 100k-employee run, POST → REVIEW-ready | ≤ 30 min | +| Self-service adoption | Monthly active employees / provisioned employees per tenant | ≥ 80% by month 3 | +| Admin efficiency | HR minutes per employee per month spent on attendance corrections | ↓ 50% vs pre-WorkTrack baseline | +| Support load | Tickets per 1,000 employees per month | ≤ 5 after month 2 | + +## 10. Appendix — Requirement traceability (FR → API → entities) + +| FR | Primary endpoints (`/v1`) | Primary entities | +|---|---|---| +| FR-ORG-001 | all (middleware) | Company | +| FR-ORG-002 | `/branches`, `/departments`, `/positions` | Branch, Department, Position | +| FR-ORG-003 | `/employees`, `POST /employees/{id}/deactivate` | Employee | +| FR-ORG-004 | all (middleware) | RoleAssignment | +| FR-ORG-005 | `POST /devices`, `DELETE /devices/{id}` | Device | +| FR-ORG-006 | `GET /me` | Employee, RoleAssignment, Company | +| FR-ORG-007 | `/employees` | Employee (`managerId`) | +| FR-ORG-008 | SSO/SCIM (P4 surface) | Employee, RoleAssignment | +| FR-ORG-009 | role admin (P3 surface) | RoleAssignment | +| FR-ORG-010 | bulk import (P3 surface) | Employee, Department, ShiftAssignment | +| FR-ATT-001/002/003/005/012 | `POST /attendance/punches`, `GET /attendance/punches` | AttendancePunch, Geofence, Device | +| FR-ATT-004 | `POST /attendance/punches` (`method:QR, kioskToken`) | AttendancePunch, Device (`KIOSK`) | +| FR-ATT-006/011 | `GET /attendance/days` | AttendanceDay, Shift, HolidayCalendar | +| FR-ATT-007 | `POST /attendance/regularizations`, `…/{id}/decide` | RegularizationRequest, AttendanceDay | +| FR-ATT-008 | `GET /attendance/days`, `GET /attendance/punches` | AttendanceDay, AttendancePunch | +| FR-ATT-009 | `POST /attendance/punches` (`method:FACE`) | AttendancePunch (`faceScore`) | +| FR-ATT-010 | `GET /attendance/days` | Shift (`overtimePolicyJson`), AttendanceDay | +| FR-SHF-001 | `/shifts` | Shift | +| FR-SHF-002/003 | `GET/PUT /rosters` | ShiftAssignment | +| FR-SHF-004/005 | `POST /shift-swaps`, `…/{id}/decide` | ShiftSwapRequest, ShiftAssignment | +| FR-SHF-006 | roster lock jobs | ShiftAssignment, AuditLog | +| FR-LVE-001 | `GET /leave/types` | LeaveType, LeavePolicy | +| FR-LVE-002/003/009 | `POST /leave/requests`, `…/{id}/decide`, `…/{id}/cancel` | LeaveRequest | +| FR-LVE-004/005 | `GET /leave/balances` | LeaveBalance | +| FR-LVE-006/008 | leave computation, attendance status | HolidayCalendar, Holiday | +| FR-PAY-001 | payroll config (P2 surfaces) | SalaryComponent, SalaryStructure, EmployeeSalary | +| FR-PAY-002/006 | `GET/POST /payroll/runs`, `…/{id}/approve` | PayrollRun | +| FR-PAY-003/008 | run calculation | Payslip, AttendanceDay, LeaveRequest | +| FR-PAY-004 | `GET /payslips`, `GET /payslips/{id}` | Payslip, PayslipLine | +| FR-PAY-005 | run calculation | SalaryComponent (`statutoryCode`) | +| FR-HRO-001 | `GET/POST /announcements` | Announcement | +| FR-HRO-002 | `GET /notifications`, `POST /notifications/{id}/read` | NotificationMessage, Device (`fcmToken`) | +| FR-HRO-003 | document endpoints (P2) | EmployeeDocument | +| FR-HRO-004 | checklist endpoints (P2) | Employee, EmployeeDocument | +| FR-HRO-005 | directory search (P3) | Employee, Position, Department, Branch | +| FR-ANA-001/002/004 | `GET /analytics/kpis` | BigQuery datasets (see `02-system-architecture.md`) | +| FR-ANA-003 | `GET /analytics/insights` | BigQuery feature tables | +| FR-PLT-001 | `GET /audit-logs` | AuditLog | +| FR-PLT-002/003 | `POST /sync/push`, `GET /sync/pull` | OutboxEntry, SyncCursor (client) | +| FR-PLT-004/006 | all (cross-cutting: versioning, envelope, rate limits) | — | +| FR-PLT-005 | webhooks/open API (P4) | — | +| FR-PLT-007 | DSR endpoints (P3) | Employee, EmployeeDocument, AuditLog | +| FR-PLT-008 | tenant provisioning (P3) | Company | diff --git a/docs/02-system-architecture.md b/docs/02-system-architecture.md new file mode 100644 index 0000000..ee91145 --- /dev/null +++ b/docs/02-system-architecture.md @@ -0,0 +1,358 @@ +# WorkTrack — System Architecture + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` + +**Purpose.** This document specifies the system architecture of the WorkTrack platform: C4-style context/container/component views, the responsibilities and contracts of each container, the multi-tenancy and request-lifecycle design, idempotency/pagination/error models, scalability analysis to 100,000 employees per tenant, failure modes and resilience mechanisms, and the Architecture Decision Records that fix the major technology choices. It is the engineering counterpart to `01-product-requirements.md`; security controls are detailed further in `07-security-architecture.md`. + +--- + +## 1. Context view (C4 level 1) + +```mermaid +flowchart TD + EMP["Employee / TEAM_LEAD / BRANCH_MANAGER
(Android app, offline-first)"] + ADM["COMPANY_ADMIN / HR_ADMIN / PAYROLL_ADMIN / AUDITOR
(Web Admin SPA)"] + KSK["KIOSK terminal
(Android tablet, kiosk mode)"] + EXT["Third-party systems
(ERP, IdP, BI) — Phase P4"] + + WT["WorkTrack Platform
(multi-tenant WFM: HRMS + Attendance +
Payroll + Shifts + Leave + Analytics)"] + + FBA["Firebase Authentication
(identity, custom claims)"] + GCP["Google Cloud
(Firestore, Functions, Tasks, Pub/Sub,
Scheduler, Storage, BigQuery, FCM)"] + + EMP -->|"REST v1 (OIDC bearer) + sync"| WT + ADM -->|"REST v1 (same API)"| WT + KSK -->|"rotating TOTP QR display"| WT + EXT -->|"open API + webhooks (P4)"| WT + WT --> FBA + WT --> GCP +``` + +System boundaries: WorkTrack owns everything inside the platform box; Firebase Auth is the identity provider; all compute/storage is GCP-managed. There is no privileged back channel — Android, Web Admin, and third parties consume the same `/v1` REST API (master spec §5). + +## 2. Container view (C4 level 2) + +```mermaid +flowchart TD + subgraph Clients + AND["Android App
Kotlin, Compose, Room, WorkManager
offline-first, outbox + delta sync"] + WEB["Web Admin SPA
React 18 + TS, Firebase Hosting
(Phase P3 build; design 06-web-admin-design.md)"] + end + + subgraph API["API tier — Cloud Functions (Node 20, TypeScript, Express)"] + GW["REST API /v1
middleware: authn → tenant → rbac → handler"] + JOBS["Job handlers
(Tasks/Pub-Sub/Scheduler targets)"] + end + + subgraph Data["Data & async tier"] + FS[("Firestore
system of record
companies/{cid}/…")] + CT["Cloud Tasks
payroll calc queues"] + PS["Pub/Sub
event fan-out"] + SCH["Cloud Scheduler
accruals, roster locks,
day computation"] + GCS[("Cloud Storage
documents, payslip PDFs,
face embeddings")] + BQ[("BigQuery
analytics warehouse")] + FCM["FCM
push notifications"] + end + + AND -->|"HTTPS + Bearer ID token"| GW + WEB -->|"HTTPS + Bearer ID token"| GW + GW --> FS + GW -->|"enqueue"| CT + GW -->|"publish"| PS + CT --> JOBS + PS --> JOBS + SCH --> JOBS + JOBS --> FS + JOBS --> GCS + JOBS --> FCM + FS -->|"export"| BQ + PS -->|"streaming events"| BQ + FCM --> AND +``` + +### 2.1 Container responsibilities + +| Container | Responsibilities | Key constraints | +|---|---|---| +| **Android app** | Offline-first client for EMPLOYEE/TEAM_LEAD/BRANCH_MANAGER personas and kiosk mode. Room is the local source of truth; UI reads only from Room (Flow-based DAOs → repositories → use cases → Compose state). Mutations write Room optimistically and enqueue OutboxEntry rows; `SyncWorker` (WorkManager) drains the outbox FIFO-per-resource and delta-pulls per SyncCursor. Module graph per master spec §6.1 (`app`, `feature:*`, `core:*`). | No direct Firestore SDK access to server-authoritative collections; all mutations via REST. Punches append-only client-side. Tokens in EncryptedSharedPreferences/Keystore. | +| **Web Admin SPA** | React 18 + TypeScript admin console (COMPANY_ADMIN, HR_ADMIN, PAYROLL_ADMIN, BRANCH_MANAGER, AUDITOR). Online-first; consumes the identical `/v1` API; served from Firebase Hosting. | No offline mutation queue; RBAC mirrored client-side for UX only. Implementation is roadmap Phase P3 (design in `06-web-admin-design.md`, referenced by master spec §2 as Phase 4 of the doc set's numbering — canonical delivery phase is P3 per §8). | +| **REST API (Cloud Functions + Express)** | Single versioned HTTP surface `/v1`. Middleware chain (authn → tenant → rbac), request validation, domain services (attendance validation, leave decisioning, sync push/pull), idempotency ledger, audit logging, RFC 7807 errors. | Stateless; min-instances configured on hot functions to bound cold starts on the punch path. Deny-by-default RBAC. | +| **Job handlers** | Same codebase, separate function targets invoked by Cloud Tasks (payroll calculation), Pub/Sub (fan-out consumers: notifications, projections, BigQuery events), Cloud Scheduler (leave accruals, roster generation/locks, AttendanceDay end-of-day sweep). | Every handler idempotent; every queue has a DLQ; job progress persisted in Firestore run documents. | +| **Firestore** | System of record. `companies/{cid}` document + sub-collections per master spec §4.6. Composite indexes on `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)`. | Security rules deny direct client access to server-authoritative collections (defense in depth behind the API). 1 write/s/document sustained limit drives the sharding design (§6.1). | +| **Cloud Tasks** | Per-tenant payroll calculation queues; controlled concurrency and rate; task = one employee batch. | Named tasks for deduplication; retry with backoff; DLQ-equivalent via max-attempt capture to Firestore. | +| **Pub/Sub** | Event fan-out: `punch.recorded`, `leave.decided`, `payslip.published`, `roster.changed` → notification fan-out, projection recompute, BigQuery streaming, (P4) webhook dispatch. | At-least-once delivery; consumers idempotent; ordering keys per employee where sequence matters. | +| **Cloud Scheduler** | Cron entry points: monthly/yearly accruals, roster lock at T-N days, rotation generation, nightly AttendanceDay sweep per timezone cohort, retention/purge jobs. | Fires a Pub/Sub message or Tasks enqueue; never does the work inline. | +| **Cloud Storage** | Employee documents, payslip PDFs, face embeddings (CMEK option). Access via short-lived signed URLs issued by the API. | No public buckets; per-tenant path prefix `tenants/{cid}/…`; raw face captures deleted post-embedding. | +| **BigQuery** | Analytics warehouse fed by Firestore export + Pub/Sub streaming. Serves `/analytics/kpis`, dashboards, and Phase P4 AI feature pipelines. | Datasets partitioned by date, clustered by `companyId`; analytics never scan Firestore. | +| **FCM** | Push delivery for NotificationMessage fan-out; token lifecycle tracked on Device rows (`fcmToken`). | Push is a hint, not a transport: clients reconcile via `/sync/pull`, so a lost push never loses data. | + +## 3. Component view — API tier (C4 level 3) + +```mermaid +flowchart TD + REQ["HTTPS request"] --> MW1["authn middleware
verify Firebase ID token"] + MW1 --> MW2["tenant middleware
claims {cid,r,b,eid} → TenantContext
URL companyId must match cid"] + MW2 --> MW3["rbac middleware
resource:action check, deny-by-default"] + MW3 --> MW4["validation + idempotency
schema check, Idempotency-Key ledger"] + MW4 --> H["domain handler"] + + subgraph Services["Domain services"] + ATT["AttendanceService
punch validation, AttendanceDay compute"] + LVE["LeaveService
requests, chains, balances"] + SHF["ShiftService
shifts, rosters, swaps"] + PAY["PayrollService
runs, calc orchestration, payslips"] + ORG["OrgService
employees, branches, RBAC admin"] + SYN["SyncService
push (outbox ops), pull (delta cursor)"] + ANA["AnalyticsService
KPI queries (BigQuery)"] + end + + H --> Services + Services --> AUD["AuditLogger
append-only AuditLog"] + Services --> REPO["Firestore repositories
tenant-scoped, ULID IDs"] + Services --> EVT["EventPublisher → Pub/Sub"] + H --> ERR["Error mapper → RFC 7807 problem+json"] +``` + +### 3.1 Component view — Android container + +The Android component structure is the master spec module graph (§6.1) rendered as dependencies: + +```mermaid +graph TD + APP["app"] --> FA["feature:auth"] + APP --> FD["feature:dashboard"] + APP --> FAT["feature:attendance"] + APP --> FL["feature:leave"] + APP --> FP["feature:payslips"] + APP --> FPR["feature:profile"] + + FA & FD & FAT & FL & FP & FPR --> DOM["core:domain
use cases + repository contracts"] + FA & FD & FAT & FL & FP & FPR --> DS["core:designsystem
M3 theme + components"] + + DATA["core:data
repository implementations"] --> DB["core:database
Room, Flow DAOs"] + DATA --> NET["core:network
REST client /v1"] + DATA --> DST["core:datastore
session, preferences"] + DATA --> DOM + SYNC["core:sync
SyncWorker, outbox processor,
WorkManager scheduling"] --> DATA + DOM --> MDL["core:model"] + DB & NET & DST --> MDL + MDL & DOM & DS --> CMN["core:common
Result, dispatchers, time"] +``` + +Responsibilities: `core:database` holds the Room schema mirroring the canonical model (§4 of the master spec) plus client-only OutboxEntry and SyncCursor tables; `core:network` is the typed `/v1` client (auth interceptor, problem+json decoding, idempotency header injection); `core:sync` owns the outbox drain (FIFO per resource) and delta pull; `core:domain` exposes use cases so `feature:*` modules never see data-layer types. Build wiring comes from the `build-logic/` convention plugins named in master spec §6.1. + +### 3.2 Key flows + +**Punch validation (server-side), `POST /attendance/punches`:** + +```mermaid +flowchart TD + A["Punch request
(GPS | QR | FACE | KIOSK | MANUAL)"] --> B{"Device bound +
Play Integrity verdict OK?"} + B -- no --> R1["Persist punch, serverValidated=false
invalidReason=integrity · 422 problem"] + B -- yes --> C{"method?"} + C -- GPS --> D{"inside geofence?
+ speed-of-travel plausible?
+ isMock false?"} + C -- QR --> E{"kioskToken HMAC valid,
within 30s window,
kiosk branch = employee branch?"} + C -- FACE --> F{"faceScore ≥ tenant threshold?"} + D & E & F -- fail --> R2["Persist append-only with
insideFence/invalidReason set
→ regularization path"] + D & E & F -- pass --> G["Persist punch
serverValidated=true"] + G --> H["Publish punch.recorded → Pub/Sub"] + H --> I["AttendanceDay recompute
(ordering key = employeeId)"] + I --> J["KPI event → BigQuery stream"] +``` + +**Sync cycle (client outbox + delta pull):** + +```mermaid +flowchart TD + M["Local mutation"] --> T["Room txn: optimistic row
(syncStatus=PENDING) + OutboxEntry
(ULID idempotencyKey)"] + T --> W["SyncWorker
(network-constrained, unique work,
exponential backoff)"] + W --> P["POST /sync/push
batched ops, FIFO per resource"] + P --> S{"per-item result"} + S -- ok --> OK["Room: syncStatus=SYNCED
server fields win · outbox DONE"] + S -- "4xx problem" --> KO["Outbox FAILED +
actionable notification
(never silent loss)"] + S -- "5xx / 429" --> RB["Keep PENDING
retry with backoff"] + OK --> PU["GET /sync/pull?types&cursor
per-resource watermark"] + PU --> AP["Apply deltas + tombstones
advance SyncCursor"] +``` + +--- + +## 4. Multi-tenancy design + +1. **Storage isolation** — every aggregate lives under `companies/{companyId}/…` sub-collections (master spec §4.6). There are no cross-tenant collections except platform-internal operator data. Collection-group queries are used only by `SUPER_ADMIN` tooling and are permission-fenced. +2. **Identity binding** — Firebase Auth custom claims carry `{ cid: companyId, r: [roleCodes], b: [branchIds], eid: employeeId }`. Claims are set server-side at employee provisioning/role change; a claim change forces token refresh (≤ 60 min natural expiry; deactivation additionally revokes refresh tokens). +3. **Request binding** — every route resolves the tenant from the **verified ID token, never from the URL alone**; if a URL carries `companyId` it must equal `cid` or the request fails with `403` (`tenant-mismatch` problem type). Repositories accept a `TenantContext` and prefix every Firestore path with it — a handler cannot physically address another tenant's collection. +4. **Scope enforcement** — RBAC scoping (COMPANY/BRANCH/DEPARTMENT via RoleAssignment) is applied as query constraints (e.g. a `BRANCH_MANAGER` roster query is forced to `branchId ∈ claims.b`), not post-filtering. +5. **Blast-radius controls** — per-tenant Cloud Tasks queues and per-tenant rate limits prevent one tenant's payroll run or sync storm from starving others; per-tenant BigQuery partitioning bounds analytics cost attribution (§6.4). + +## 5. Cross-cutting API design + +### 5.1 Request lifecycle + +Middleware order is fixed: `authn → tenant → rbac → validation/idempotency → handler → audit/event → response`. Failures short-circuit with RFC 7807 bodies. Every request carries a generated `requestId` (returned as `X-Request-Id`, logged, and attached to problem responses as `instance`). + +### 5.2 Idempotency design + +- `Idempotency-Key` header honored on **all POSTs** (master spec §5). Clients use ULIDs; the Android outbox uses the OutboxEntry `idempotencyKey`. +- Ledger: `companies/{cid}/idempotency/{key}` document storing `{requestHash, status, responseSnapshot, createdAt, expiresAt}`. TTL 24 h (sync/punch) to 30 days (payroll run creation). +- Semantics: first request executes inside a transaction that also creates the ledger entry; replay with same key + same `requestHash` returns the stored response with `Idempotency-Replayed: true`; same key + different hash → `409 idempotency-key-reuse`; concurrent duplicate (`status=IN_PROGRESS`) → `409` with `Retry-After`. +- Append-only punches get a second guard: the punch ID itself is the client ULID, so even a ledger miss cannot double-insert. + +### 5.3 Pagination / cursor design + +- All list endpoints: `?cursor&limit` (default 25, max 100 for interactive; `/sync/pull` max 500). Envelope: `{ "data": [...], "meta": { "cursor": "..." } }`; absent `meta.cursor` = last page. +- Cursor = opaque base64url token encoding `{orderField(s), lastValues, direction, filterHash}` + HMAC. Tampering or reuse across a changed filter set → `400 invalid-cursor`. +- Ordering is always over an indexed, unique-suffixed key (e.g. `(date, id)` or `(updatedAt, id)` using ULID tiebreaker) so pagination is stable under concurrent writes. +- `/sync/pull` cursors are per resource type (client SyncCursor rows) and are watermark cursors over `(updatedAt, id)`; deletes are delivered as tombstones (`deletedAt` set) so clients can converge. + +### 5.4 Error model (RFC 7807) + +`Content-Type: application/problem+json`. Problem `type` URIs are stable API contract: `https://api.worktrack.app/problems/`. + +```json +{ + "type": "https://api.worktrack.app/problems/outside-geofence", + "title": "Punch outside geofence", + "status": 422, + "detail": "Location is 412 m from branch fence 'HQ-North' (radius 150 m).", + "instance": "/v1/attendance/punches/01J8ZQ…", + "requestId": "req_01J8ZQ…", + "errors": [{ "field": "lat", "reason": "outside_fence" }] +} +``` + +Canonical problem catalog (excerpt): `validation-failed` (400), `invalid-cursor` (400), `unauthenticated` (401), `permission-denied` / `tenant-mismatch` (403), `not-found` (404), `conflict` / `idempotency-key-reuse` / `version-conflict` (409), `outside-geofence` / `integrity-verdict-failed` / `insufficient-balance` / `kiosk-token-invalid` (422), `rate-limited` (429, with `Retry-After`), `internal` (500), `dependency-unavailable` (503). The Android sync layer maps 4xx problems to actionable user notifications and 5xx/429 to retry-with-backoff. + +--- + +## 6. Scalability analysis + +### 6.1 Firestore write sharding for hot aggregates + +Hot spots and their treatment: + +| Hot aggregate | Load pattern | Design | +|---|---|---| +| `punches` | Burst at shift boundaries (thousands of writes/min/tenant) | Naturally sharded: one document per punch, ULID doc IDs (near-monotonic but written across many employees → no single hot document; collection index fan-in is the limit, monitored). | +| `attendanceDays` | One doc per employee/date, recomputed on punch/regularization | Document key `{employeeId}_{date}` — writes distribute across employees; per-document rate is ≤ a few writes/day. Recompute is event-driven (Pub/Sub, ordering key = employeeId) + nightly sweep; `version` field makes recompute last-writer-safe. | +| Company-level counters (present count, live KPI tiles) | Every punch would touch one doc → exceeds 1 write/s/doc | **Sharded counters**: `attendanceDayAgg/{date}/shards/{0..N}` (N sized by branch headcount, default 20); readers sum shards; N is resizable online. At ≥ 5k employees/branch these counters are dropped entirely in favor of BigQuery-served KPIs. | +| `payrollRuns` progress | 100k task completions updating one run doc | Tasks update per-batch progress docs `payrollRuns/{id}/batches/{n}`; a Pub/Sub-driven aggregator folds batch states into the run doc at ≤ 1 write/s. | +| Idempotency ledger | Bursty on sync push | Keyed by client ULID → uniformly distributed; TTL-expired via scheduled purge. | + +### 6.2 Fan-out strategies + +- **Notification fan-out** (announcement to 100k employees): the API writes the Announcement once and publishes to Pub/Sub; a consumer expands the audience in pages of 500, writing NotificationMessage docs via BulkWriter and batching FCM sends (500/multicast). No request-path fan-out. +- **Projection fan-out** (punch → AttendanceDay → KPI event): chained through Pub/Sub with per-employee ordering keys; each stage idempotent (recompute-from-source, not increment). +- **Roster fan-out**: rotation generation emits per-branch jobs; each job writes ShiftAssignments in 500-doc batches. + +### 6.3 Scaling to 100k employees per tenant (explicit design) + +| Concern | Naive approach (rejected) | 100k design | +|---|---|---| +| Roster generation (100k × 28 days ≈ 2.8M ShiftAssignments) | Single function invocation loops all employees — exceeds function timeout, memory | Cloud Scheduler → orchestrator enqueues **batched Cloud Tasks jobs** (1 task = 1 branch or 1k-employee slice); each task writes ≤ 500-doc batches with progress checkpoints; resumable at slice granularity; target ≤ 15 min end-to-end | +| Payroll run (100k payslips) | Synchronous calculation in the API request | `POST /payroll/runs` returns `202`-style DRAFT→CALCULATING immediately; orchestrator shards employees into **Cloud Tasks queue** batches (250/task, per-tenant queue with capped dispatch rate); per-batch results in sub-docs; failed employees quarantined to an exceptions list without failing the run; target ≤ 30 min | +| Analytics/KPIs | Firestore collection scans + in-memory aggregation | **BigQuery instead of Firestore aggregation**: Firestore export + Pub/Sub streaming keep BQ ≤ 24 h fresh (streamed events near-real-time); `/analytics/kpis` queries partitioned/clustered BQ tables; Firestore serves only small precomputed counter tiles at low headcounts | +| Attendance day sweep | One nightly job for all tenants | Timezone-cohort scheduling: Scheduler fires per timezone offset; per-tenant per-branch tasks; only employees with activity or expected shifts are touched (query on `(status, updatedAt)` index) | +| Sync pull after long offline | Unbounded delta | Watermark cursor + 500-doc pages + per-type prioritization (punches/assignments first); server caps a single pull session and the client resumes — no timeout cliffs | +| Directory search | Firestore prefix queries at 100k | Search index in BigQuery (P3) or dedicated index; Firestore remains source of record | + +### 6.4 Per-tenant isolation & cost controls + +- Per-tenant Cloud Tasks queues (payroll) and per-tenant rate limits (API) bound noisy-neighbor impact. +- Cost attribution: Pub/Sub event stream aggregates per-tenant document read/write counts into a daily BigQuery cost table (NFR-CST-001); plan enforcement (Company `plan`) throttles or gates expensive features (analytics ranges, export frequency). +- Firestore read amplification is bounded by design: clients read projections (AttendanceDay) not raw punches; list endpoints cap ranges (≤ 92 days); dashboards read BigQuery. + +## 7. Failure modes & resilience + +| Failure | Detection | Response | Degradation | +|---|---|---|---| +| API unavailable / network loss (client) | OkHttp failures, sync errors | Outbox retains ops; WorkManager retries with exponential backoff + jitter (network-constrained, unique work) | Full offline operation from Room ≥ 72 h; UI shows sync state, never blocks punch capture | +| Firestore unavailable | Health checks, error rates | Functions return `503 dependency-unavailable` with `Retry-After`; clients back off | Reads may be served stale from client cache; no writes accepted (no write-behind on server) | +| Cloud Tasks handler crash | Task retry with backoff (max 10 attempts) | Idempotent handlers re-run safely; after max attempts, task payload captured to `deadLetters` collection + alert | Payroll batch marked failed-quarantined; run continues; operator re-drives from DLQ | +| Pub/Sub consumer failure | Redelivery, DLQ topic after 5 attempts | DLQ subscription + replayer tool; consumers idempotent so replay is safe | Projections lag; source of record unaffected; KPI staleness visible via `computedAt` | +| Duplicate delivery (Tasks/PubSub at-least-once) | — | Idempotency by natural keys (`{employeeId}_{date}`, punch ULIDs, run+batch IDs) | None — by construction | +| Kiosk offline | Kiosk detects staleness | TOTP QRs are generated locally from a provisioned secret — kiosk keeps issuing valid codes offline; employee app queues the punch | Server validates on sync within skew window; branch mismatch still enforced server-side | +| FCM push loss | — | Push is advisory; `/sync/pull` on app foreground reconciles | Delayed notification, no data loss | +| Clock skew (client) | Server compares `punchedAt` vs receipt time | Outside skew bound → punch stored with `invalidReason=clock-skew`, flagged for regularization | Employee informed; no silent rejection | +| Sync conflict (server rejects op) | 4xx problem on `/sync/push` item | Per-item results in batch response; client marks OutboxEntry FAILED and raises an actionable notification (master spec §6.3.6) | Never silent data loss; user can amend and resubmit | +| Regional outage | Cloud Monitoring | Multi-region Firestore (nam5/eur3-class) rides zone loss; regional function outage → status page, error budget consumed | Offline-first clients absorb API downtime for field workflows | + +Retry policy summary: client outbox — exponential backoff with jitter, base 30 s, cap 1 h, retained until explicit failure classification (4xx = terminal → user action; 5xx/429 = retry). Server-to-server — Tasks/PubSub native retries, handlers idempotent, DLQ after bounded attempts, replay tooling + alerting on DLQ depth > 0. + +## 8. Operational architecture + +### 8.1 Environments & deployment + +| Environment | Purpose | Data | Notes | +|---|---|---|---| +| `dev` | Per-engineer iteration | Synthetic seed tenants | Firebase Emulator Suite (Auth, Firestore, Functions) for local work; shared dev project for integration | +| `staging` | Pre-release validation | Synthetic incl. the 100k-employee load tenant | Mirrors prod config incl. Firestore indexes, Scheduler jobs, queues; release-gate suites run here | +| `prod` | Customer traffic | Tenant data, region-pinned | Progressive rollout; Android via Play staged rollout, functions via traffic-safe deploy | + +CI/CD: trunk-based; every merge runs unit + rules-emulator + API contract tests; staging deploy on merge; prod deploy is a tagged release with automated canary checks against SLO burn (rollback = redeploy previous tag; Firestore schema changes are additive-only, so rollback never needs data migration). Android release train is fortnightly; server API remains backward-compatible with the two previous app versions (additive `/v1` evolution per master spec §3.4). + +### 8.2 Observability + +- **Correlation** — `X-Request-Id` generated at ingress, propagated into logs, Pub/Sub message attributes, Cloud Tasks payloads, and RFC 7807 `requestId`; a payroll run's `runId` links every batch log. +- **Metrics** — RED per endpoint (rate, errors, duration histograms) tagged by tenant plan tier (not tenant ID, to bound cardinality); queue depth, DLQ depth, job durations, sync push batch outcomes, punch validation outcomes by `invalidReason`. +- **SLO monitoring** — burn-rate alerts on NFR-AVL/NFR-LAT budgets (`01-product-requirements.md` §6); paging on fast burn, ticketing on slow burn. +- **Logs** — structured JSON, PII-free by lint-enforced logging helpers; audit-relevant events go to AuditLog (the product feature), operational logs to Cloud Logging (30-day retention). +- **Client telemetry** — crash reporting plus sync-health beacons (outbox depth, oldest PENDING age); a fleet-wide rise in oldest-PENDING age is the leading indicator of a sync regression. + +### 8.3 Data lifecycle & retention + +| Data class | Store | Retention | Disposal | +|---|---|---|---| +| AttendancePunch, AttendanceDay | Firestore (+ BigQuery) | 7 years (payroll-affecting) | Archive to Storage export, then purge job | +| AuditLog | Firestore (+ BigQuery) | ≥ 7 years, immutable | Legal-hold aware purge | +| Payslip, PayrollRun | Firestore + PDF in Storage | ≥ 7 years | Never purged while tenant active without legal review | +| Face embeddings | Cloud Storage (CMEK option) | Employment + 30 days | Hard delete on exit/opt-out; raw captures deleted post-embedding (never retained) | +| EmployeeDocument | Cloud Storage | Per-kind policy, tenant-configurable | Signed-URL access only; delete on DSR where lawful | +| NotificationMessage | Firestore | 180 days | TTL purge | +| Idempotency ledger | Firestore | 24 h – 30 days by endpoint class | TTL purge | +| Operational logs | Cloud Logging | 30 days | Automatic | +| DSR erasure | cross-cutting | — | PII pseudonymized in place; financial/statutory records retain integrity (FR-PLT-007) | + +--- + +## 9. Appendix — Architecture Decision Records + +### ADR-001 — Firestore vs Cloud SQL as system of record +- **Context.** The system of record must serve thousands of tenants, offline-syncing mobile clients, per-tenant isolation, and spiky write bursts at shift boundaries, with a small platform team and no DBA capacity. +- **Decision.** Firestore, laid out as `companies/{cid}` sub-collections; relational integrity enforced in the service layer; analytics offloaded to BigQuery. +- **Consequences.** (+) Zero-ops horizontal scale, per-document ACLs as defense-in-depth, natural fit for delta sync (`updatedAt` watermarks), multi-region durability. (−) No joins/aggregates — requires projections (AttendanceDay), sharded counters, and BigQuery for analytics; 1 write/s/doc constraint shapes design (§6.1); cross-entity invariants (leave balances) need transactions and `version` fields. Revisit if a workload emerges that requires multi-entity transactions beyond Firestore's limits. + +### ADR-002 — ULID identifiers +- **Context.** Offline clients must create entities (punches, leave requests) without a server round-trip; IDs must be globally unique, sortable for cursors, and index-friendly. +- **Decision.** ULIDs everywhere (client- and server-generated), doubling as idempotency keys for created resources. +- **Consequences.** (+) Offline generation, lexicographic time-ordering enables `(field, id)` cursor tiebreaks, no coordination. (−) IDs embed creation time (minor information leak — acceptable, IDs are never exposed unauthenticated); near-monotonic doc IDs could hot-spot a single-collection index at extreme write rates — mitigated because writes spread across per-tenant collections and many employees. + +### ADR-003 — Server-authoritative writes for money/compliance paths +- **Context.** Attendance validity, leave balances, and payroll affect pay and legal compliance; offline clients can hold stale state or be tampered with. +- **Decision.** Clients propose, the server decides (master spec §3.1): punch validity, AttendanceDay computation, balance movements, and payroll math execute exclusively server-side; Firestore rules deny direct client writes to these collections. +- **Consequences.** (+) Single point of truth and audit; tamper resistance; recompute is always possible from append-only sources. (−) Offline UX shows provisional state (`syncStatus=PENDING`) that may later be rejected — mitigated by actionable rejection notifications and the regularization path; server must be sized for all computation. + +### ADR-004 — Client outbox pattern with idempotency keys +- **Context.** Offline-first mutations need exactly-once effect over an at-least-once network, ordered per resource, surviving process death. +- **Decision.** Every local mutation enqueues a durable OutboxEntry (ULID `idempotencyKey`, FIFO per resource) in Room; `SyncWorker` drains via `POST /sync/push` batches; the server's idempotency ledger (§5.2) deduplicates. +- **Consequences.** (+) Exactly-once effect, crash-safe, testable queue semantics, uniform mutation path. (−) Two write paths on client (optimistic row + outbox) must stay consistent — enforced by writing both in one Room transaction; queue-head failures block a resource's queue — mitigated by terminal/retryable error classification (§7). + +### ADR-005 — REST over gRPC +- **Context.** Two first-party clients (Android, browser SPA) plus future third-party integrators; Cloud Functions HTTP triggers; team debugging ergonomics. +- **Decision.** Versioned JSON REST (`/v1`) with RFC 7807 errors, cursor pagination, and idempotency headers; no gRPC surface. +- **Consequences.** (+) Browser-native, curl-debuggable, gateway/CDN-friendly, trivially consumable by partners (OpenAPI in P4); Cloud Functions HTTP fit. (−) No streaming (acceptable: sync is pull-based; push hints via FCM), no generated strong contracts — mitigated with OpenAPI-driven codegen for the Retrofit and web clients; JSON overhead acceptable at our payload sizes. + +### ADR-006 — Cloud Functions vs Cloud Run for the API tier +- **Context.** Choice of serverless compute for Express: Functions (per-function deploy, scale-to-zero) vs Cloud Run (container, concurrency > 1, fewer cold-start pathologies). +- **Decision.** Cloud Functions (Node 20) for P0–P2, with the Express app structured as a standard container-ready codebase; min-instances on the punch/sync functions to bound cold starts. +- **Consequences.** (+) Lowest ops burden, native Firebase integration (auth context, deploy tooling), per-function scaling and IAM. (−) Cold starts and per-instance concurrency=1 cost more at high QPS; migration path to Cloud Run is explicitly preserved (no Functions-only APIs in handler code; Express app is host-agnostic). Trigger to migrate: sustained QPS where Run's concurrency materially cuts cost, or p95 latency breaches from cold starts. + +### ADR-007 — Append-only events for punches and audit logs +- **Context.** Attendance punches and audit trails are legally sensitive; offline sync of mutable records requires conflict resolution. +- **Decision.** AttendancePunch and AuditLog are append-only and immutable (master spec §4.2, §4.5); corrections are new facts (RegularizationRequest) not edits; derived state (AttendanceDay) is recomputed, never hand-edited. +- **Consequences.** (+) No sync conflicts by construction, tamper-evidence, deterministic recomputation, simple client contract (no update/delete ops). (−) Storage grows monotonically — bounded by retention/archival policies (BigQuery + Storage export before purge); "wrong" punches remain visible — presented with `invalidReason` and superseding regularizations. + +### ADR-008 — BigQuery for analytics instead of Firestore aggregation +- **Context.** KPIs, trends, and AI features over 100k-employee tenants; Firestore cannot aggregate and per-read costs make scans prohibitive. +- **Decision.** Firestore → BigQuery export plus Pub/Sub streaming events populate a tenant-partitioned warehouse; `/analytics/kpis` and `/analytics/insights` read BigQuery only; Firestore keeps at most small precomputed counter tiles for low-headcount real-time widgets. +- **Consequences.** (+) SQL analytics at scale, ML feature pipelines (Phase P4) get a native home, cost per query is bounded by partitioning/clustering. (−) Freshness ≤ 24 h for export-fed tables (streamed events narrow this); a second data platform to operate — accepted as the price of correct tool separation: Firestore for transactions, BigQuery for analysis. diff --git a/docs/03-database-design.md b/docs/03-database-design.md new file mode 100644 index 0000000..03e3876 --- /dev/null +++ b/docs/03-database-design.md @@ -0,0 +1,735 @@ +# WorkTrack — Database Design + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` (§4, §6.3) + +**Purpose.** This document specifies the persistence layer of WorkTrack end-to-end: the normalized logical model (3NF), its projection onto the two physical stores — Firestore (server system of record) and Room (Android offline store) — the full data dictionary for every entity in master spec §4, the Firestore collection/index/sharding plan for 100k-employee tenants, the on-device schema and retention windows, and the data lifecycle including BigQuery archival, soft-delete semantics, and GDPR erasure via crypto-shredding. It is the binding contract for `core:database` (Room), the Cloud Functions data access layer, and the Firestore security-rules model. + +--- + +## 1. Modeling approach + +### 1.1 Logical model: 3NF + +The canonical model in master spec §4 is maintained in third normal form: + +- **1NF** — all attributes atomic; repeating groups are extracted (e.g. `PayslipLine` rows instead of an amounts array; `Holiday` rows instead of a date list on `HolidayCalendar`). +- **2NF** — no partial dependencies on composite keys; every entity has a single surrogate ULID primary key, and natural keys (`Employee.employeeCode`, `Shift.code`, `LeaveType.code`) are enforced as unique constraints, not identifiers. +- **3NF** — no transitive dependencies: employee org placement lives only on `Employee` (`branchId`, `departmentId`, `positionId`); pay composition lives only on `SalaryComponent`/`SalaryStructure`; shift timing lives only on `Shift`. + +Two classes of entity deliberately relax pure normalization, exactly as §4 declares: + +| Class | Entities | Rationale | +|---|---|---| +| Append-only event logs | `AttendancePunch`, `AuditLog` | Immutable facts; no updates ⇒ no update anomalies, no sync conflicts | +| Server-computed projections | `AttendanceDay`, `LeaveBalance`, `PayrollRun.totalsJson` | Derived aggregates materialized for read performance; recomputable from events; guarded by `version` for optimistic concurrency | + +### 1.2 Mapping to the two physical stores + +| Concern | Firestore (server) | Room (Android) | +|---|---|---| +| Unit | Document in a per-tenant sub-collection (`companies/{cid}/…`, §4.6) | Row in a SQLite table, one table per entity | +| Primary key | Document ID = entity `id` (ULID, §3 below) | `id TEXT PRIMARY KEY` (same ULID) | +| Foreign keys | By-ID reference fields; integrity enforced in the API layer (Firestore has no FK constraints) | Declared `FOREIGN KEY` with `ON DELETE NO ACTION`; indices on every FK column | +| Enums | Uppercase string codes as written in §4 | `TEXT` + `@TypeConverter` to Kotlin enums | +| `*Json` fields | Nested map on the document | `TEXT` column holding canonical JSON (kotlinx.serialization) | +| Timestamps | Firestore `Timestamp` | `INTEGER` epoch millis UTC | +| Dates | `"yyyy-MM-dd"` string (timezone-independent business date) | `TEXT` ISO date | +| Tenancy | Structural (sub-collection path) + `companyId` field duplicated on the doc for collection-group queries and BigQuery export | `companyId` column; single-tenant device, kept for integrity checks | +| Concurrency | `updateTime` preconditions + `version` field on projections | `syncStatus` column; server fields win on reconcile (§6.3 of master spec) | + +The same ULID is the identifier in both stores and in the REST API — there is no ID translation layer. Clients generate ULIDs offline; the server accepts them for client-originated aggregates (punches, leave requests, regularizations, swap requests) and generates them for server-originated ones (attendance days, payslips, payroll runs). + +--- + +## 2. ER diagrams + +Entities shown with key/discriminator fields; the full field list is in the data dictionary (§4 of this document). Entities suffixed `_REF` are cross-domain references owned by the Org & Identity diagram. `?` in a comment means nullable. + +### 2.1 Org & Identity + +```mermaid +erDiagram + COMPANY ||--o{ BRANCH : "operates" + COMPANY ||--o{ DEPARTMENT : "defines" + COMPANY ||--o{ POSITION : "defines" + COMPANY ||--o{ EMPLOYEE : "employs" + BRANCH |o--o{ DEPARTMENT : "hosts (optional)" + DEPARTMENT |o--o{ DEPARTMENT : "parent of" + DEPARTMENT |o--o{ POSITION : "groups (optional)" + BRANCH ||--o{ EMPLOYEE : "home branch" + DEPARTMENT ||--o{ EMPLOYEE : "assigned" + POSITION ||--o{ EMPLOYEE : "holds" + EMPLOYEE |o--o{ EMPLOYEE : "manages" + EMPLOYEE ||--o{ ROLE_ASSIGNMENT : "granted" + EMPLOYEE ||--o{ DEVICE : "binds" + + COMPANY { + ulid id PK + string name + string timezone + string currency + } + BRANCH { + ulid id PK + ulid companyId FK + string code + double lat + double lng + int radiusM + } + DEPARTMENT { + ulid id PK + ulid companyId FK + ulid branchId FK "?" + ulid parentDepartmentId FK "?" + string code + } + POSITION { + ulid id PK + ulid companyId FK + ulid departmentId FK "?" + string code + int level + } + EMPLOYEE { + ulid id PK + ulid companyId FK + string employeeCode "unique per company" + ulid branchId FK + ulid departmentId FK + ulid positionId FK + ulid managerId FK "?" + enum status "ACTIVE|ON_LEAVE|SUSPENDED|EXITED" + string authUid "Firebase Auth UID" + } + ROLE_ASSIGNMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + string roleCode + enum scopeType "COMPANY|BRANCH|DEPARTMENT" + ulid scopeId "?" + } + DEVICE { + ulid id PK + ulid companyId FK + ulid employeeId FK + timestamp boundAt + timestamp revokedAt "?" + } +``` + +### 2.2 Attendance & Scheduling + +```mermaid +erDiagram + BRANCH_REF ||--o{ GEOFENCE : "covers" + EMPLOYEE_REF ||--o{ SHIFT_ASSIGNMENT : "scheduled" + SHIFT ||--o{ SHIFT_ASSIGNMENT : "instantiated as" + SHIFT_ASSIGNMENT ||--o{ SHIFT_SWAP_REQUEST : "subject of" + EMPLOYEE_REF ||--o{ SHIFT_SWAP_REQUEST : "requests" + EMPLOYEE_REF ||--o{ ATTENDANCE_PUNCH : "records" + GEOFENCE |o--o{ ATTENDANCE_PUNCH : "matched by (optional)" + DEVICE_REF ||--o{ ATTENDANCE_PUNCH : "originates" + EMPLOYEE_REF ||--o{ ATTENDANCE_DAY : "summarized per date" + SHIFT |o--o{ ATTENDANCE_DAY : "evaluated against" + EMPLOYEE_REF ||--o{ REGULARIZATION_REQUEST : "files" + + GEOFENCE { + ulid id PK + ulid companyId FK + ulid branchId FK + double lat + double lng + int radiusM + } + SHIFT { + ulid id PK + ulid companyId FK + string code + string startTime + string endTime + boolean isNight + } + SHIFT_ASSIGNMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid shiftId FK + date date + ulid branchId FK + enum source "ROSTER|ROTATION|MANUAL|SWAP" + string status + } + SHIFT_SWAP_REQUEST { + ulid id PK + ulid companyId FK + ulid requesterId FK + ulid targetEmployeeId FK "?" + ulid assignmentId FK + string status + } + ATTENDANCE_PUNCH { + ulid id PK "append-only" + ulid companyId FK + ulid employeeId FK + timestamp punchedAt + enum type "IN|OUT" + enum method "GPS|QR|FACE|MANUAL|KIOSK" + ulid geofenceId FK "?" + boolean insideFence + boolean serverValidated + } + ATTENDANCE_DAY { + ulid id PK "server-computed" + ulid companyId FK + ulid employeeId FK + date date "unique with employeeId" + ulid shiftId FK "?" + enum status "PRESENT|ABSENT|HALF_DAY|LEAVE|HOLIDAY|WEEK_OFF|PENDING" + int version "optimistic lock" + } + REGULARIZATION_REQUEST { + ulid id PK + ulid companyId FK + ulid employeeId FK + date date + enum status "PENDING|APPROVED|REJECTED|CANCELLED" + ulid decidedBy "?" + } +``` + +### 2.3 Leave + +```mermaid +erDiagram + LEAVE_TYPE ||--o{ LEAVE_POLICY : "governed by" + LEAVE_TYPE ||--o{ LEAVE_BALANCE : "tracked per employee-year" + LEAVE_TYPE ||--o{ LEAVE_REQUEST : "requested as" + EMPLOYEE_REF ||--o{ LEAVE_BALANCE : "owns" + EMPLOYEE_REF ||--o{ LEAVE_REQUEST : "files" + EMPLOYEE_REF |o--o{ LEAVE_REQUEST : "current approver of" + HOLIDAY_CALENDAR ||--o{ HOLIDAY : "contains" + + LEAVE_TYPE { + ulid id PK + ulid companyId FK + string code + boolean isPaid + } + LEAVE_POLICY { + ulid id PK + ulid companyId FK + ulid leaveTypeId FK + enum accrualRule "NONE|MONTHLY|YEARLY|ANNIVERSARY" + double accrualDays + json appliesToJson + } + LEAVE_BALANCE { + ulid id PK "server-computed" + ulid companyId FK + ulid employeeId FK + ulid leaveTypeId FK + int periodYear "unique with employeeId+leaveTypeId" + double pendingDays + int version "optimistic lock" + } + LEAVE_REQUEST { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid leaveTypeId FK + date startDate + date endDate + double days + enum status "DRAFT|PENDING|APPROVED|REJECTED|CANCELLED" + json approvalChainJson + ulid currentApproverId FK "?" + } + HOLIDAY_CALENDAR { + ulid id PK + ulid companyId FK + int year + json branchIdsJson + } + HOLIDAY { + ulid id PK + ulid calendarId FK + date date + boolean isOptional + } +``` + +### 2.4 Payroll & Platform + +```mermaid +erDiagram + SALARY_COMPONENT }o--o{ SALARY_STRUCTURE : "composed via componentIdsJson" + SALARY_STRUCTURE ||--o{ EMPLOYEE_SALARY : "applied as" + EMPLOYEE_REF ||--o{ EMPLOYEE_SALARY : "compensated by (effective-dated)" + PAYROLL_RUN ||--o{ PAYSLIP : "produces" + EMPLOYEE_REF ||--o{ PAYSLIP : "paid via" + PAYSLIP ||--|{ PAYSLIP_LINE : "itemized by" + EMPLOYEE_REF ||--o{ EMPLOYEE_DOCUMENT : "owns" + EMPLOYEE_REF ||--o{ NOTIFICATION_MESSAGE : "receives" + EMPLOYEE_REF ||--o{ AUDIT_LOG : "acts in" + COMPANY_REF ||--o{ ANNOUNCEMENT : "publishes" + + SALARY_COMPONENT { + ulid id PK + ulid companyId FK + string code + enum type "EARNING|DEDUCTION|EMPLOYER_COST" + enum calc "FIXED|PERCENT_OF_BASIC|PERCENT_OF_GROSS|FORMULA" + } + SALARY_STRUCTURE { + ulid id PK + ulid companyId FK + json componentIdsJson + } + EMPLOYEE_SALARY { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid structureId FK + double basicAmount + date effectiveFrom + date effectiveTo "?" + } + PAYROLL_RUN { + ulid id PK + ulid companyId FK + int periodYear + int periodMonth + enum status "DRAFT|CALCULATING|REVIEW|APPROVED|PAID|CLOSED" + ulid startedBy FK + timestamp lockedAt "?" + } + PAYSLIP { + ulid id PK + ulid companyId FK + ulid runId FK + ulid employeeId FK + double gross + double net + string status + } + PAYSLIP_LINE { + ulid id PK + ulid payslipId FK + string componentCode "snapshot" + string componentName "snapshot" + double amount + } + ANNOUNCEMENT { + ulid id PK + ulid companyId FK + json audienceJson + timestamp publishAt + } + EMPLOYEE_DOCUMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + string kind + string storagePath + } + AUDIT_LOG { + ulid id PK "append-only immutable" + ulid companyId FK + ulid actorId FK + string action + string resourceType + ulid resourceId + timestamp at + } + NOTIFICATION_MESSAGE { + ulid id PK + ulid companyId FK + ulid employeeId FK + string kind + timestamp sentAt + } +``` + +--- + +## 3. Identifier strategy + +- **ULIDs everywhere** (26-char Crockford base32). Sortable by creation time, generatable offline on Android with zero coordination, collision-safe (80 bits of randomness). The ULID is simultaneously the Room PK, the Firestore document ID, and the REST resource ID. +- Client-originated entities (punches, leave requests, regularizations, swap requests, devices, outbox ops) mint their ULID on-device; the server persists it verbatim, which makes retries naturally idempotent. +- Natural business keys (`employeeCode`, `Shift.code`, `LeaveType.code`, `SalaryComponent.code`, `Branch.code`) are unique **within a company** and enforced by API-layer transactional lookups (Firestore has no unique constraints); Room mirrors them with `UNIQUE` indices. +- Hot append-only collections use a **shard-prefixed document ID** (see §5.3) to defeat index hotspotting caused by ULID monotonicity; the `id` field inside the document remains the pure ULID. + +--- + +## 4. Data dictionary + +Types: `ULID`, `STRING`, `TEXT` (long-form), `TS` (timestamp: Firestore `Timestamp` / Room epoch-millis), `DATE` (ISO `yyyy-MM-dd`), `TIME` (`HH:mm`), `INT`, `DOUBLE`, `BOOL`, `ENUM`, `JSON`. + +**Common columns (present on every entity, listed once).** Every entity carries `id ULID PK` plus the audit block `createdAt TS NOT NULL`, `updatedAt TS NOT NULL`, `deletedAt TS NULL` (soft delete, §7.3). Every entity except `Company`, `Holiday` (keyed by `calendarId`), `PayslipLine` (keyed by `payslipId`), and the two client-only tables carries `companyId ULID NOT NULL`. Room rows additionally carry `syncStatus ENUM(PENDING|SYNCED|FAILED) NOT NULL` — client-only, never serialized to the server. The tables below list entity-specific fields only. Field names with a typographic space in master spec §4 (`appliesTo Json`, `branchIds Json`, `componentIds Json`, `meta Json`) are physically stored as `appliesToJson`, `branchIdsJson`, `componentIdsJson`, `metaJson`. + +### 4.1 Org & Identity + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| Company | name | STRING | N | Display name | +| Company | legalName | STRING | N | Registered legal entity name | +| Company | timezone / currency | STRING | N | IANA zone / ISO 4217 — tenant defaults | +| Company | status / plan | STRING | N | `ACTIVE`/`SUSPENDED`/`CHURNED`; billing plan code | +| Company | settingsJson | JSON | N | Tenant feature flags, punch policy, week-off config | +| Branch | name / code | STRING | N | `code` unique per company | +| Branch | address | STRING | N | Postal address | +| Branch | lat / lng | DOUBLE | N | Branch centroid (default geofence anchor) | +| Branch | radiusM | INT | N | Default geofence radius, meters | +| Branch | timezone / status | STRING | N | Overrides company zone; `ACTIVE`/`CLOSED` | +| Department | branchId | ULID | Y | Null = company-wide department | +| Department | name / code | STRING | N | `code` unique per company | +| Department | parentDepartmentId | ULID | Y | Self-reference; hierarchy, cycle-checked in API | +| Position | title / code | STRING | N | `code` unique per company | +| Position | level | INT | N | Seniority band (1 = entry) | +| Position | departmentId | ULID | Y | Optional department binding | +| Employee | employeeCode | STRING | N | Unique per company; human-readable | +| Employee | firstName / lastName | STRING | N | PII — envelope-encrypted (§7.4) | +| Employee | email / phone | STRING | N | PII — envelope-encrypted; email unique per company | +| Employee | avatarUrl | STRING | Y | Cloud Storage URL; PII | +| Employee | branchId / departmentId / positionId | ULID | N | Org placement FKs | +| Employee | managerId | ULID | Y | Self-reference → approval chain root | +| Employee | employmentType | ENUM | N | `FULL_TIME\|PART_TIME\|CONTRACT\|INTERN` | +| Employee | joinDate / exitDate | DATE | N / Y | `exitDate` set by deactivation flow | +| Employee | status | ENUM | N | `ACTIVE\|ON_LEAVE\|SUSPENDED\|EXITED` | +| Employee | authUid | STRING | N | Firebase Auth UID; unique globally | +| RoleAssignment | employeeId | ULID | N | | +| RoleAssignment | roleCode | STRING | N | Built-in or custom role code (§1.1 master spec) | +| RoleAssignment | scopeType | ENUM | N | `COMPANY\|BRANCH\|DEPARTMENT` | +| RoleAssignment | scopeId | ULID | Y | Null when scopeType=COMPANY | +| Device | employeeId | ULID | N | | +| Device | platform / model / appVersion | STRING | N | e.g. `android` / `Pixel 9` / `1.4.2` | +| Device | fcmToken | STRING | N | Push token; rotated in place | +| Device | integrityVerdict | STRING | N | Last Play Integrity verdict summary | +| Device | boundAt / revokedAt | TS | N / Y | Non-null `revokedAt` = binding revoked; punches rejected | + +### 4.2 Attendance & Scheduling + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| Geofence | branchId | ULID | N | | +| Geofence | name / active | STRING / BOOL | N | | +| Geofence | lat / lng | DOUBLE | N | Centroid | +| Geofence | radiusM | INT | N | Meters; server clamps to [30, 2000] | +| Shift | name / code | STRING | N | `code` unique per company | +| Shift | startTime / endTime | TIME | N | Local to branch timezone; `isNight` handles wrap | +| Shift | breakMinutes / graceInMinutes / graceOutMinutes | INT | N | Unpaid break; lateness/early-out tolerance | +| Shift | overtimePolicyJson | JSON | N | Threshold, multiplier, rounding, cap | +| Shift | isNight / active | BOOL | N | `isNight`: end time on next calendar day | +| ShiftAssignment | employeeId / shiftId / branchId | ULID | N | | +| ShiftAssignment | date | DATE | N | Unique with `employeeId` | +| ShiftAssignment | source | ENUM | N | `ROSTER\|ROTATION\|MANUAL\|SWAP` | +| ShiftAssignment | status | STRING | N | `SCHEDULED`, `LOCKED`, `CANCELLED` | +| ShiftSwapRequest | requesterId | ULID | N | | +| ShiftSwapRequest | targetEmployeeId | ULID | Y | Null = open-shift claim pool | +| ShiftSwapRequest | assignmentId | ULID | N | FK → ShiftAssignment | +| ShiftSwapRequest | status | STRING | N | `PENDING`, `ACCEPTED`, `APPROVED`, `REJECTED`, `CANCELLED` | +| ShiftSwapRequest | decidedBy / decidedAt | ULID / TS | Y | Manager decision | +| AttendancePunch | employeeId | ULID | N | Append-only: no update/delete ever | +| AttendancePunch | punchedAt | TS | N | Client capture time; server plausibility-checked | +| AttendancePunch | type | ENUM | N | `IN\|OUT` | +| AttendancePunch | method | ENUM | N | `GPS\|QR\|FACE\|MANUAL\|KIOSK` | +| AttendancePunch | lat / lng / accuracyM | DOUBLE | Y | GPS methods only | +| AttendancePunch | geofenceId | ULID | Y | Matched fence, if any | +| AttendancePunch | insideFence | BOOL | N | Server-evaluated at write | +| AttendancePunch | deviceId | ULID | N | Bound device FK | +| AttendancePunch | kioskId / faceScore | ULID / DOUBLE | Y | QR-kiosk id / FACE embedding match score | +| AttendancePunch | photoUrl / note | STRING | Y | Optional capture (PII) / employee note | +| AttendancePunch | serverValidated | BOOL | N | False until server rules pass | +| AttendancePunch | invalidReason | STRING | Y | e.g. `GEOFENCE_VIOLATION`, `MOCK_LOCATION`, `IMPLAUSIBLE_SPEED` | +| AttendanceDay | employeeId | ULID | N | Projection; unique with `date` | +| AttendanceDay | date | DATE | N | Business date in shift timezone | +| AttendanceDay | shiftId | ULID | Y | Resolved assignment for the date | +| AttendanceDay | firstInAt / lastOutAt | TS | Y | | +| AttendanceDay | workedMinutes / breakMinutes / lateMinutes / earlyOutMinutes / overtimeMinutes | INT | N | Computed vs shift + grace + OT policy | +| AttendanceDay | status | ENUM | N | `PRESENT\|ABSENT\|HALF_DAY\|LEAVE\|HOLIDAY\|WEEK_OFF\|PENDING` | +| AttendanceDay | computedAt / version | TS / INT | N | Last recompute time; optimistic lock, bump on recompute | +| RegularizationRequest | employeeId | ULID | N | | +| RegularizationRequest | date | DATE | N | Target attendance date | +| RegularizationRequest | requestedInAt / requestedOutAt | TS | Y | At least one required (API rule) | +| RegularizationRequest | reason | TEXT | N | | +| RegularizationRequest | status | ENUM | N | `PENDING\|APPROVED\|REJECTED\|CANCELLED` | +| RegularizationRequest | approverChainJson | JSON | N | Ordered approver steps + decisions | +| RegularizationRequest | decidedBy / decidedAt | ULID / TS | Y | Final decision | + +### 4.3 Leave + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| LeaveType | name / code | STRING | N | `code` unique per company (e.g. `AL`, `SL`) | +| LeaveType | colorHex | STRING | N | UI swatch | +| LeaveType | isPaid / requiresAttachment / active | BOOL | N | `isPaid` drives payroll `lopDays`; attachment e.g. medical certificate | +| LeavePolicy | leaveTypeId | ULID | N | | +| LeavePolicy | accrualRule | ENUM | N | `NONE\|MONTHLY\|YEARLY\|ANNIVERSARY` | +| LeavePolicy | accrualDays | DOUBLE | N | Days per accrual event | +| LeavePolicy | maxBalance / maxCarryover | DOUBLE | N | Caps applied by accrual engine | +| LeavePolicy | minNoticedays | INT | N | Minimum notice before startDate | +| LeavePolicy | maxConsecutiveDays | INT | N | Per-request cap | +| LeavePolicy | appliesToJson | JSON | N | Audience selector: branches/departments/employmentTypes | +| LeaveBalance | employeeId / leaveTypeId | ULID | N | Unique with `periodYear` | +| LeaveBalance | periodYear | INT | N | Balance period | +| LeaveBalance | entitledDays / accruedDays / usedDays / carriedOverDays / pendingDays | DOUBLE | N | Server-maintained; half-day granularity (0.5) | +| LeaveBalance | version | INT | N | Optimistic lock for decide/cancel transactions | +| LeaveRequest | employeeId / leaveTypeId | ULID | N | | +| LeaveRequest | startDate / endDate | DATE | N | Inclusive range | +| LeaveRequest | startHalf / endHalf | BOOL | N | Half-day flags on boundary dates | +| LeaveRequest | days | DOUBLE | N | Server-computed net of holidays/week-offs | +| LeaveRequest | reason | TEXT | N | | +| LeaveRequest | attachmentUrl | STRING | Y | Required when `LeaveType.requiresAttachment` | +| LeaveRequest | status | ENUM | N | `DRAFT\|PENDING\|APPROVED\|REJECTED\|CANCELLED` | +| LeaveRequest | approvalChainJson | JSON | N | Ordered steps: approverId, role, decision, at, comment | +| LeaveRequest | currentApproverId | ULID | Y | Head of pending chain; drives approvals inbox | +| LeaveRequest | decidedAt | TS | Y | Terminal decision time | +| HolidayCalendar | name / year | STRING / INT | N | | +| HolidayCalendar | branchIdsJson | JSON | N | Branches the calendar applies to; empty = all | +| Holiday | calendarId | ULID | N | Parent key (no `companyId`; tenancy via parent path) | +| Holiday | date | DATE | N | Unique within calendar | +| Holiday | name | STRING | N | | +| Holiday | isOptional | BOOL | N | Optional/restricted holiday | + +### 4.4 Payroll & Platform + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| SalaryComponent | name / code | STRING | N | `code` unique per company (e.g. `BASIC`, `HRA`) | +| SalaryComponent | type | ENUM | N | `EARNING\|DEDUCTION\|EMPLOYER_COST` | +| SalaryComponent | calc | ENUM | N | `FIXED\|PERCENT_OF_BASIC\|PERCENT_OF_GROSS\|FORMULA` | +| SalaryComponent | value | DOUBLE | N | Amount or percent per `calc` | +| SalaryComponent | formula | STRING | Y | Expression, `calc=FORMULA` only | +| SalaryComponent | taxable / active | BOOL | N | | +| SalaryComponent | statutoryCode | STRING | Y | Hook for statutory packs (P2) | +| SalaryStructure | name | STRING | N | | +| SalaryStructure | componentIdsJson | JSON | N | Ordered component ID list | +| EmployeeSalary | employeeId / structureId | ULID | N | | +| EmployeeSalary | basicAmount | DOUBLE | N | Minor-unit-safe decimal; currency below | +| EmployeeSalary | currency | STRING | N | ISO 4217 | +| EmployeeSalary | effectiveFrom / effectiveTo | DATE | N / Y | Effective-dated, non-overlapping per employee; null `effectiveTo` = current | +| EmployeeSalary | revisionReason | STRING | N | e.g. `ANNUAL_REVIEW`, `PROMOTION` | +| PayrollRun | periodYear / periodMonth | INT | N | Unique with `branchIdsJson` scope (API-enforced) | +| PayrollRun | branchIdsJson | JSON | N | Run scope; empty = all branches | +| PayrollRun | status | ENUM | N | `DRAFT\|CALCULATING\|REVIEW\|APPROVED\|PAID\|CLOSED` | +| PayrollRun | startedBy / approvedBy | ULID | N / Y | | +| PayrollRun | totalsJson | JSON | N | Denormalized run totals: headcount, gross, net, per-component sums | +| PayrollRun | lockedAt | TS | Y | Non-null = source data frozen | +| Payslip | runId / employeeId | ULID | N | Unique pair | +| Payslip | periodYear / periodMonth | INT | N | Copied from run (query independence) | +| Payslip | currency | STRING | N | Snapshot from EmployeeSalary | +| Payslip | gross / totalDeductions / net | DOUBLE | N | | +| Payslip | workedDays / paidLeaveDays / lopDays | DOUBLE | N | From AttendanceDay + LeaveRequest projections | +| Payslip | overtimeMinutes | INT | N | | +| Payslip | status | STRING | N | `DRAFT`, `FINAL`, `VOID` | +| Payslip | pdfUrl | STRING | Y | Rendered artifact in Cloud Storage | +| PayslipLine | payslipId | ULID | N | Parent key (no `companyId`; tenancy via parent) | +| PayslipLine | componentCode / componentName / type | STRING | N | **Snapshots** of SalaryComponent at calc time (§5.5) | +| PayslipLine | amount | DOUBLE | N | Signed by `type` convention | +| PayslipLine | metaJson | JSON | N | Calc trace: base, rate, formula inputs | +| Announcement | title / body | STRING/TEXT | N | | +| Announcement | audienceJson | JSON | N | Branch/department/role selectors | +| Announcement | publishAt / expiresAt | TS | N / Y | | +| Announcement | createdBy | ULID | N | | +| Announcement | priority | STRING | N | `NORMAL`, `HIGH`, `URGENT` | +| EmployeeDocument | employeeId | ULID | N | | +| EmployeeDocument | kind | STRING | N | `ID_PROOF`, `CONTRACT`, `CERTIFICATE`, … | +| EmployeeDocument | name / storagePath / mimeType | STRING | N | Cloud Storage object | +| EmployeeDocument | sizeBytes | INT | N | | +| EmployeeDocument | expiresAt | TS | Y | Document validity (visas, permits) | +| EmployeeDocument | verifiedBy | ULID | Y | HR verifier | +| AuditLog | actorId / actorRole | ULID / STRING | N | Immutable, append-only | +| AuditLog | action | STRING | N | e.g. `employee.update`, `payroll.approve` | +| AuditLog | resourceType / resourceId | STRING / ULID | N | | +| AuditLog | beforeJson / afterJson | JSON | Y | Redacted diffs (no PII plaintext) | +| AuditLog | ip / userAgent | STRING | Y | | +| AuditLog | at | TS | N | Event time (distinct from createdAt) | +| NotificationMessage | employeeId | ULID | N | | +| NotificationMessage | kind | STRING | N | `LEAVE_DECIDED`, `PUNCH_REJECTED`, `PAYSLIP_READY`, … | +| NotificationMessage | title / body / dataJson | STRING/STRING/JSON | N | `dataJson` carries deep link | +| NotificationMessage | readAt / sentAt | TS | Y / N | | +| OutboxEntry *(client)* | opType | ENUM | N | `CREATE\|UPDATE\|DELETE` (punches: CREATE only) | +| OutboxEntry *(client)* | resourceType / resourceId | STRING / ULID | N | | +| OutboxEntry *(client)* | payloadJson | JSON | N | Serialized request body | +| OutboxEntry *(client)* | idempotencyKey | ULID | N | Sent as `Idempotency-Key` header | +| OutboxEntry *(client)* | attempts / lastError | INT / STRING | N / Y | | +| OutboxEntry *(client)* | state | ENUM | N | `PENDING\|IN_FLIGHT\|DONE\|FAILED` | +| OutboxEntry *(client)* | queuedAt | TS | N | FIFO order per resourceType | +| SyncCursor *(client)* | resourceType | STRING | N | PK (no ULID id) | +| SyncCursor *(client)* | cursor | STRING | N | Opaque server cursor | +| SyncCursor *(client)* | lastSyncedAt | TS | N | | + +--- + +## 5. Firestore physical design + +### 5.1 Collection layout + +Exactly as master spec §4.6 — one tenant root document plus flat sub-collections per aggregate: + +``` +companies/{cid} — Company doc + branches/{id} departments/{id} positions/{id} + employees/{id} roleAssignments/{id} devices/{id} + geofences/{id} shifts/{id} shiftAssignments/{id} + punches/{sid} attendanceDays/{sid} regularizations/{id} + leaveTypes/{id} leavePolicies/{id} leaveBalances/{id} + leaveRequests/{id} holidayCalendars/{id} ── holidayCalendars/{id}/holidays/{id} + salaryComponents/{id} salaryStructures/{id} employeeSalaries/{id} + payrollRuns/{id} payslips/{id} ── payslips/{id}/lines/{id} + announcements/{id} documents/{id} auditLogs/{sid} + notifications/{id} +``` + +- `{id}` = ULID; `{sid}` = shard-prefixed ULID (§5.3). +- `Holiday` and `PayslipLine` are the only nested sub-sub-collections; both are small, parent-bounded child sets always read with their parent. +- Firestore security rules deny all direct client writes to these collections and allow reads only for a narrow self-service subset (own notifications, active announcements); everything else flows through the REST API (master spec §7). + +### 5.2 Composite index plan + +Firestore auto-indexes single fields; the composite entries below are declared in `firestore.indexes.json`. All are collection-scope within the tenant sub-collection (tenant isolation is structural), plus collection-group entries where BigQuery/ops tooling needs cross-tenant scans. + +| Collection | Index (order matters) | Query served | +|---|---|---| +| punches | `employeeId ASC, punchedAt DESC` | `GET /attendance/punches?employeeId` history, day recompute fan-in | +| punches | `deviceId ASC, punchedAt DESC` | Device forensics, speed-of-travel plausibility lookback | +| punches | `serverValidated ASC, punchedAt DESC` | Invalid-punch review queue | +| attendanceDays | `employeeId ASC, date DESC` | `GET /attendance/days?employeeId&from&to` | +| attendanceDays | `date ASC, status ASC` | Daily branch/company presence dashboards | +| attendanceDays | `status ASC, updatedAt DESC` | Pending-computation sweep; anomaly review | +| shiftAssignments | `employeeId ASC, date ASC` | Employee roster view; punch-time shift resolution | +| shiftAssignments | `branchId ASC, date ASC` | `GET /rosters?branchId&from&to` grid | +| regularizations | `status ASC, updatedAt DESC` | Approvals inbox | +| regularizations | `employeeId ASC, date DESC` | Employee history | +| leaveRequests | `employeeId ASC, startDate DESC` | Self-service list | +| leaveRequests | `currentApproverId ASC, status ASC, updatedAt DESC` | Approvals inbox (pending-for-me) | +| leaveRequests | `status ASC, updatedAt DESC` | HR review queues | +| leaveBalances | `employeeId ASC, periodYear DESC` | `GET /leave/balances?employeeId` | +| payslips | `employeeId ASC, periodYear DESC, periodMonth DESC` | `GET /payslips?employeeId&year` | +| payslips | `runId ASC, status ASC` | Run review screen | +| notifications | `employeeId ASC, sentAt DESC` | `GET /notifications` | +| auditLogs | `resourceType ASC, at DESC` | `GET /audit-logs?resourceType&from&to` | +| auditLogs | `actorId ASC, at DESC` | Actor-centric audit review | +| *every synced collection* | `updatedAt ASC, __name__ ASC` | `GET /sync/pull` delta scan with stable tie-break | + +The `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)` families mandated by master spec §4.6 are the first three rows of each group above. + +### 5.3 Write sharding for hot paths + +At 100k active employees a tenant produces ~200k+ punches/day, concentrated in shift-start bursts (≈2–5k writes/min for 15-minute windows). Two Firestore hotspots must be engineered around: (a) sustained write rates to a collection whose **document IDs are monotonically increasing** (ULIDs are), and (b) single-field index ranges on monotonically increasing values (`punchedAt`, `updatedAt`). + +Mitigations, applied to `punches`, `attendanceDays`, and `auditLogs`: + +1. **Shard-prefixed document IDs.** Document ID = `s{NN}_{ulid}` where `NN = crc32(employeeId) mod 32`, zero-padded. Writes spread across 32 key ranges; the pure ULID remains in the `id` field and in the API. Reads are unaffected: every production query on these collections filters by `employeeId`, `deviceId`, or an indexed field — never by document ID range. +2. **Burst absorption via Pub/Sub.** The punch API path does one document write (the punch) synchronously; `AttendanceDay` recomputation is fanned out through Pub/Sub with per-employee ordering keys and batched (debounce 30s), so the projection collection sees at most one write per employee per burst instead of one per punch. +3. **No sequential-index range scans on the hot path.** The sync `updatedAt ASC` scan is issued per-tenant with cursor + limit (≤500), which Firestore serves without hotspotting; company-wide dashboards read pre-aggregated KPI docs (below), not raw punches. +4. **Aggregate documents with sharded counters.** Daily per-branch presence counters (`present`, `late`, `absent`) live in 16 counter shards per branch-day, summed on read by the analytics endpoints. + +### 5.4 Document ID and query discipline + +- Never query across tenants at runtime; collection-group queries are reserved for offline jobs (BigQuery export backfill, SUPER_ADMIN tooling). +- All list endpoints translate to a single composite-index query + cursor (`startAfter`), never `OFFSET`-style skips. +- Multi-entity invariants (leave decide + balance debit; payroll approve + payslip finalize) run in Firestore transactions with `version` preconditions on projection docs. + +### 5.5 Denormalization decisions + +| Duplicated data | Where | Why | Reconciliation | +|---|---|---|---| +| `componentCode`, `componentName`, `type` | `PayslipLine` (from SalaryComponent) | Payslips are legal artifacts; must render identically forever even if the component is renamed or deleted | Never — snapshot is intentional and immutable once `Payslip.status=FINAL` | +| `employeeName`, `employeeCode` snapshot | `Payslip` (additive snapshot fields; from Employee) | Same immutability requirement; also survives GDPR crypto-shredding as pseudonymized payroll record (§7.4) | Never after FINAL | +| `insideFence`, `geofenceId` | `AttendancePunch` (from Geofence evaluation) | Punch validity must reflect the fence **as it was at punch time**; fences change | Never — append-only | +| `branchId` | `ShiftAssignment` (from Employee/roster context) | Roster queries by branch without joining employees | Roster write path sets it | +| `days` | `LeaveRequest` (derivable from dates + calendar) | Balance math and approver UX need the server-computed figure; holiday calendars change | Recomputed only on request edit while `DRAFT` | +| `periodYear`, `periodMonth` | `Payslip` (from PayrollRun) | Employee payslip list queries without run lookup | Copied at creation | +| `totalsJson` | `PayrollRun` (sum of payslips) | Review screen reads one doc, not 100k payslips | Rebuilt by calculation job; frozen at `lockedAt` | +| `title`, `body` | `NotificationMessage` (from source event) | Notification must render after source mutation/deletion | Never | +| Role/branch claims `{cid, r, b, eid}` | Firebase Auth custom claims (from RoleAssignment) | Zero-read authz on every request | Claims rebuilt on RoleAssignment change; ≤1h propagation via forced token refresh | +| `AttendanceDay` (entire entity) | Projection of punches × shifts × leave × holidays | O(1) reads for calendars, payroll input, KPIs | Recomputed on any contributing event; `version`-guarded | + +--- + +## 6. Room schema (Android) + +### 6.1 On-device tables and retention windows + +Room holds the **current user's slice** of the tenant, not the tenant. All tables carry the common columns incl. `syncStatus`. DAOs expose `Flow`s; repositories never read the network directly (master spec §6.3). + +| Table | Scope on device | Local retention | Notes | +|---|---|---|---| +| `employees` | Self + org directory (id, name, avatar, position, branch — no PII beyond directory fields) | Directory: full; refreshed via sync | Approvers additionally cache direct reports | +| `branches`, `departments`, `positions` | All active | Full | Small reference data | +| `devices` | Own bindings | Full | | +| `geofences` | Own branch's active fences | Full | Needed for punch pre-check UX (client hint only; server re-validates) | +| `shifts` | All active | Full | | +| `shift_assignments` | Own, date ∈ [today−30d, today+30d] | 60-day sliding window | | +| `punches` | Own | **90 days** | Append-only; local rows past window purged by `RetentionWorker` | +| `attendance_days` | Own | 90 days | | +| `regularization_requests` | Own + pending-for-me (approvers) | 180 days | | +| `leave_types`, `leave_policies` | All active | Full | | +| `leave_balances` | Own, current + previous periodYear | 2 periods | | +| `leave_requests` | Own + pending-for-me (approvers) | 365 days | | +| `holiday_calendars`, `holidays` | Applicable to own branch, current + next year | 2 years | | +| `payslips` (+ `payslip_lines`) | Own | 24 months | PDF fetched on demand, not stored | +| `announcements` | Active, audience-matched | Until `expiresAt` + 30d | | +| `notifications` | Own | 90 days | | +| `outbox_entries` | Client-only | Until `DONE` + 7d (diagnostics) | §6.2 | +| `sync_cursors` | Client-only | Permanent | One row per synced resourceType | + +**Not on device:** `roleAssignments` (own effective permissions cached in DataStore from `GET /me`, not Room), `auditLogs`, `salaryComponents`, `salaryStructures`, `employeeSalaries`, `payrollRuns`, `documents` metadata beyond own list. Salary configuration and audit data never leave the server to reduce device exposure. + +### 6.2 Client-only tables + +- **`outbox_entries`** — the mutation queue (fields in §4.4). Unique index on `idempotencyKey`; partial index on `(state, queuedAt)` for FIFO drain per `resourceType`. `SyncWorker` transitions `PENDING → IN_FLIGHT → DONE|FAILED`; `FAILED` ops surface as actionable notifications and are never silently dropped. +- **`sync_cursors`** — one opaque cursor per resourceType, advanced only after a pull page is fully applied in a Room transaction (crash-safe resume). + +### 6.3 Schema management + +Room `version` tracked in `core:database`; destructive migrations forbidden in release builds — every schema change ships a `Migration` with an instrumentation test against exported schemas (`schemas/` directory committed). `RetentionWorker` (WorkManager, daily, charging-preferred) enforces the windows above with `DELETE` by watermark — local purge only, never synced. + +--- + +## 7. Data lifecycle + +### 7.1 Retention (server) + +| Data | Hot (Firestore) | Archive (BigQuery) | Basis | +|---|---|---|---| +| Punches | 13 months | 7 years | Payroll evidence, labor-law audit | +| AttendanceDays | 25 months | 7 years | Year-over-year analytics | +| Leave requests/balances | 25 months | 7 years | Dispute resolution | +| Payslips, payroll runs | Life of tenant | 10 years | Statutory financial retention | +| Audit logs | 13 months | 7 years | SOC 2 | +| Notifications | 6 months | — | Ephemeral | +| Devices (revoked) | 12 months after `revokedAt` | — | Fraud forensics | +| Face embeddings | Life of employment; deleted at exit + 30d | Never exported | Master spec §7 | + +A scheduled `retentionSweep` job (Cloud Scheduler, nightly, per-tenant fan-out via Cloud Tasks) deletes Firestore docs past their hot window **after** confirming the BigQuery row exists. + +### 7.2 Archival to BigQuery + +- Continuous export: Firestore change streams → Pub/Sub → a streaming loader into per-entity BigQuery tables (`worktrack_raw.{collection}`), partitioned by ingestion date, clustered on `(companyId, employeeId)` where applicable. +- BigQuery is the substrate for the analytics endpoints' offline aggregates, AI insights (P4), and the long-term archive; it is never read on interactive API paths. +- Deletions propagate as tombstone rows (`deletedAt` set), so BigQuery is append-only and auditable; GDPR erasure is handled by crypto-shredding (§7.4), not row deletion. + +### 7.3 Soft delete + +- Deletable entities set `deletedAt` (never physical delete on the interactive path). All list queries filter `deletedAt == null`; direct GET of a soft-deleted resource returns the RFC 7807 `NOT_FOUND` problem (see `04-api-design.md`). +- Soft-deleted docs still flow through `GET /sync/pull` as tombstones (`op: "TOMBSTONE"`), which is how clients learn to remove local rows. +- Append-only entities (`AttendancePunch`, `AuditLog`) are **never** deleted or tombstoned inside the retention window; invalidation is expressed by `serverValidated=false` + `invalidReason`. +- Physical deletion happens only in `retentionSweep` (past hot window) or DSR fulfillment. + +### 7.4 GDPR erasure — crypto-shredding + +PII fields (`Employee.firstName/lastName/email/phone/avatarUrl`, `AttendancePunch.photoUrl` blobs, `EmployeeDocument` blobs, face embeddings) are envelope-encrypted with a **per-employee data encryption key (DEK)** stored in a `keyring` collection, itself wrapped by a Cloud KMS key (CMEK-capable per master spec §7). + +Erasure flow (DSR endpoint, P3): + +1. Verify request scope; place a legal-hold check (open payroll disputes block erasure of payroll-relevant identity). +2. Destroy the employee's DEK (KMS `Destroy` on the wrapping material + delete keyring doc). All encrypted PII — in Firestore, in backups, and in BigQuery exports — becomes unrecoverable simultaneously, without touching the archive. +3. Overwrite plaintext directory projections (name on directory cache, notification bodies) with `"Erased User"`; payslip snapshots keep `employeeCode` (pseudonym) and drop the name snapshot where statute permits, otherwise retain under the statutory-retention lawful basis. +4. Delete Cloud Storage objects (avatar, documents, face embeddings) and revoke devices. +5. Write an `AuditLog` entry (`action: "gdpr.erase"`) containing only pseudonymous identifiers. + +Backups therefore need no rewrite: restoring a backup restores ciphertext whose key no longer exists. diff --git a/docs/04-api-design.md b/docs/04-api-design.md new file mode 100644 index 0000000..24ad34c --- /dev/null +++ b/docs/04-api-design.md @@ -0,0 +1,582 @@ +# WorkTrack — REST API Design (v1) + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` (§2, §3, §5, §7); entity schemas in `03-database-design.md` + +**Purpose.** This document is the binding contract for the WorkTrack REST API served by Cloud Functions (Node 20, TypeScript, Express) at `https://api.worktrack.app/v1` and consumed by the Android app and the Web Admin SPA. It defines the cross-cutting conventions (authentication, tenancy, errors, pagination, idempotency, versioning, rate limits), the complete endpoint reference for every route in master spec §5 with permissions and schemas, full request/response examples for the critical flows, sequence diagrams for the four hardest interactions, and the P4 webhook design. Field names and types are those of `03-database-design.md`; nothing here redefines the data model. + +--- + +## 1. Conventions + +### 1.1 Base URL, transport, media types + +- Base: `https://api.worktrack.app/v1`. TLS 1.2+ only. All bodies are `application/json; charset=utf-8`; errors are `application/problem+json`. +- Timestamps: RFC 3339 UTC (`2026-07-17T09:02:11.482Z`). Business dates: `yyyy-MM-dd`, interpreted in the relevant branch timezone. Monetary amounts: JSON numbers with at most 2 fraction digits in the resource `currency`. +- All IDs are ULIDs (26-char Crockford base32). + +### 1.2 Authentication and tenancy + +- `Authorization: Bearer ` on every request (no exceptions; there are no anonymous routes). +- Middleware chain per master spec §7: **verify token → load tenant context → RBAC permission check → handler**; deny-by-default. +- Tenant is resolved from the verified custom claims `{ cid, r, b, eid }` — never from the URL alone. Any resource whose `companyId` differs from `cid` yields `TENANT_MISMATCH` (not `NOT_FOUND`, to make cross-tenant probing visible in audit logs; the response body carries no resource data). +- Punch and device endpoints additionally require a non-revoked `Device` binding and an acceptable Play Integrity verdict (master spec §7); failures map to `PERMISSION_DENIED` with `detail` explaining the integrity gate. + +### 1.3 Permission model + +Permissions are `resource:action` strings (master spec §1.1), bundled into roles. The reference below lists the permission each endpoint requires. **Scope is orthogonal to the permission string**: the RBAC layer intersects the permission with the caller's `RoleAssignment` scope (`COMPANY | BRANCH | DEPARTMENT`) and with self-scope for `EMPLOYEE`-role access (e.g. `payslip:read` as EMPLOYEE returns only `employeeId == eid`). `AUDITOR` holds the `:read` set plus `audit:read`. `SUPER_ADMIN` bypasses tenant scoping via internal tooling only — never through this public surface. + +### 1.4 Errors — RFC 7807 `problem+json` + +Every non-2xx response is a problem document: + +```json +{ + "type": "https://api.worktrack.app/errors/geofence-violation", + "title": "Punch outside geofence", + "status": 422, + "code": "GEOFENCE_VIOLATION", + "detail": "Location is 412 m from geofence 'HQ Tower' (radius 150 m).", + "instance": "/v1/attendance/punches", + "traceId": "8f4c1b2e9d3a4f60", + "errors": [ { "field": "lat", "reason": "OUTSIDE_FENCE" } ] +} +``` + +`code` is the machine-stable contract; `type`/`title`/`detail` may evolve. `errors[]` appears only on validation failures. Canonical codes: + +| `code` | HTTP | Meaning | Client action | +|---|---|---|---| +| `UNAUTHENTICATED` | 401 | Missing/expired/invalid ID token | Refresh token via Firebase SDK, retry once | +| `PERMISSION_DENIED` | 403 | Authenticated but lacks permission, scope, or device/integrity gate | Do not retry; surface to user | +| `TENANT_MISMATCH` | 403 | URL/body `companyId` ≠ token claim `cid` | Do not retry; forces re-login | +| `VALIDATION_FAILED` | 400 | Body/query fails schema or business validation | Fix input; `errors[]` lists fields | +| `IDEMPOTENCY_REPLAY` | 409 | `Idempotency-Key` reused with a **different** payload | Bug on client; do not retry | +| `GEOFENCE_VIOLATION` | 422 | GPS punch outside every active fence (and policy forbids) | Show distance hint; allow note/regularization | +| `KIOSK_TOKEN_INVALID` | 422 | QR token signature/window/branch check failed | Rescan fresh QR | +| `CONFLICT` | 409 | State conflict: version mismatch, duplicate natural key, illegal status transition | Re-read resource, reconcile, maybe retry | +| `RATE_LIMITED` | 429 | Tier budget exhausted | Back off per `Retry-After` | +| `NOT_FOUND` | 404 | Resource absent or soft-deleted within tenant | Remove local copy on sync | + +### 1.5 Pagination envelope + +All list endpoints are cursor-based: `?cursor=&limit=<1..200, default 50>`. Responses always use the envelope: + +```json +{ "data": [ … ], "meta": { "cursor": "eyJ1IjoiMjAyNi0w…", "hasMore": true } } +``` + +`meta.cursor` is opaque, resource-specific, valid ≥24h, and `null` on the last page. Single-resource responses use `{ "data": {…}, "meta": {} }`. Cursors encode an index position (`updatedAt` + doc name tie-break), never an offset. + +### 1.6 Idempotency + +- `Idempotency-Key: ` is honored on **all POSTs** and required on the mutation POSTs the Android outbox emits (`/attendance/punches`, `/leave/requests`, `/attendance/regularizations`, `/shift-swaps`, `/sync/push`, `/payroll/runs`, decide/cancel endpoints). +- The server persists `(cid, key) → response` for 48h. Same key + byte-identical payload ⇒ the stored response is replayed with `Idempotency-Replayed: true` and the original status code. Same key + different payload ⇒ `409 IDEMPOTENCY_REPLAY`. +- Inside `POST /sync/push`, each op's `opId` is its idempotency key (per-op dedupe); the request-level header dedupes the whole batch. + +### 1.7 Versioning and deprecation + +- Path-versioned (`/v1`). Evolution is **additive only**: new optional fields, new endpoints, new enum values (clients must tolerate unknown enum values and unknown fields). +- Breaking changes require `/v2`. A deprecated endpoint or version emits `Deprecation: true` and `Sunset: ` headers for a minimum **180-day** window, is announced in release notes, and is monitored for traffic before removal. +- Enum value retirement follows the same 180-day rule with dual-emit. + +### 1.8 Rate limiting + +Enforced per token (per device for kiosk role), fixed-window with burst allowance. Headers on every response: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`; 429s add `Retry-After`. + +| Tier | Applies to | Sustained | Burst | +|---|---|---|---| +| Interactive | All GET/POST from user tokens | 60 req/min | 120 | +| Sync | `/sync/push`, `/sync/pull` | 12 req/min | 24 | +| Punch | `POST /attendance/punches` | 6 req/min | 10 | +| Admin bulk | Org CRUD, rosters PUT, payroll | 120 req/min | 240 | +| Kiosk | `KIOSK`-role token endpoints | 30 req/min per device | 60 | + +--- + +## 2. Endpoint reference + +Notation: request/response schemas use `field: type` shorthand; `?` marks optional/nullable. Resource schemas (full field lists) are those of the data dictionary in `03-database-design.md`; server-managed fields (`id` unless client-minted, `companyId`, `createdAt`, `updatedAt`, `deletedAt`, computed fields) are never accepted in request bodies and always present in responses. Every endpoint can return `UNAUTHENTICATED`, `PERMISSION_DENIED`, `TENANT_MISMATCH`, `RATE_LIMITED`; the Errors column lists only endpoint-specific cases. + +### 2.1 Session & devices + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /me` | *(any authenticated)* | — | `200` `{ employee: Employee, company: Company, roles: [{roleCode, scopeType, scopeId?}], permissions: [string], device?: Device }` | — | +| `POST /devices` | `device:bind` | `{ id: ulid, platform: string, model: string, appVersion: string, fcmToken: string, integrityToken: string }` | `201` `Device` | `VALIDATION_FAILED` (integrity verdict unacceptable), `CONFLICT` (binding limit reached) | +| `DELETE /devices/{id}` | `device:revoke` | — | `204` | `NOT_FOUND` | + +`GET /me` is the client bootstrap: it returns the effective permission set (mirrored client-side for UX only — enforcement is server-side) and is cached in DataStore, not Room. + +### 2.2 Org + +CRUD follows one pattern per resource — `GET /{res}` (list, cursor), `GET /{res}/{id}`, `POST /{res}`, `PUT /{res}/{id}`, `DELETE /{res}/{id}` (soft delete): + +| Resource | Permissions (list/read · create · update · delete) | Create/update body | Notes | +|---|---|---|---| +| `/branches` | `branch:read` · `branch:create` · `branch:update` · `branch:delete` | `{ name, code, address, lat, lng, radiusM, timezone, status }` | `CONFLICT` on duplicate `code` | +| `/departments` | `department:read` · `department:create` · `department:update` · `department:delete` | `{ name, code, branchId?, parentDepartmentId? }` | `VALIDATION_FAILED` on hierarchy cycle | +| `/positions` | `position:read` · `position:create` · `position:update` · `position:delete` | `{ title, code, level, departmentId? }` | | +| `/employees` | `employee:read` · `employee:create` · `employee:update` · `employee:delete` | `{ employeeCode, firstName, lastName, email, phone, avatarUrl?, branchId, departmentId, positionId, managerId?, employmentType, joinDate }` | Create provisions the Firebase Auth user and claims; `CONFLICT` on duplicate `employeeCode`/`email` | + +- `GET /employees?branchId&departmentId&status&q&cursor&limit` — directory search; `q` matches name/code prefix. `200` `{ data: [Employee], meta: { cursor } }`. +- `POST /employees/{id}/deactivate` — `employee:deactivate`. Body `{ exitDate: date, reason: string }`. Sets `status=EXITED`, `exitDate`, revokes devices and refresh tokens, cancels future shift assignments and pending requests. `200` `Employee`. Errors: `CONFLICT` (already `EXITED`), `NOT_FOUND`. + +### 2.3 Attendance + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `POST /attendance/punches` | `attendance:punch` | see §3.1 | `201` `AttendancePunch` | `VALIDATION_FAILED`, `GEOFENCE_VIOLATION`, `KIOSK_TOKEN_INVALID`, `PERMISSION_DENIED` (device revoked / integrity), `CONFLICT` (duplicate direction within debounce) | +| `GET /attendance/punches?employeeId&from&to&cursor&limit` | `attendance:read` | — | `200` `{ data: [AttendancePunch], meta }` | `VALIDATION_FAILED` (range > 92 days) | +| `GET /attendance/days?from&to&employeeId&cursor&limit` | `attendance:read` | — | `200` `{ data: [AttendanceDay], meta }` | `VALIDATION_FAILED` | +| `POST /attendance/regularizations` | `attendance:regularize` | `{ id: ulid, date, requestedInAt?, requestedOutAt?, reason }` (≥1 timestamp) | `201` `RegularizationRequest` (`status=PENDING`, chain built) | `VALIDATION_FAILED`, `CONFLICT` (open request exists for date) | +| `POST /attendance/regularizations/{id}/decide` | `attendance:approve` | `{ decision: "APPROVE"\|"REJECT", comment?: string }` | `200` `RegularizationRequest`; on final APPROVE emits synthetic `MANUAL` punches and recomputes the day | `NOT_FOUND`, `CONFLICT` (not pending / not current approver), `VALIDATION_FAILED` | + +### 2.4 Shifts & rosters + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| CRUD `/shifts` | `shift:read` / `shift:create` / `shift:update` / `shift:delete` | `{ name, code, startTime, endTime, breakMinutes, graceInMinutes, graceOutMinutes, overtimePolicyJson, isNight, active }` | standard | `CONFLICT` (duplicate `code`; delete with future assignments) | +| `GET /rosters?branchId&from&to` | `roster:read` | — | `200` `{ data: [ShiftAssignment], meta }` grouped client-side into the grid | `VALIDATION_FAILED` (range > 62 days) | +| `PUT /rosters?branchId&from&to` | `roster:write` | `{ assignments: [{ id: ulid, employeeId, shiftId, date, source }] }` — full replacement of the window | `200` `{ applied: int, removed: int }` | `VALIDATION_FAILED` (employee not in branch; overlapping night shifts), `CONFLICT` (window locked) | +| `POST /shift-swaps` | `shift_swap:create` | `{ id: ulid, assignmentId, targetEmployeeId? }` | `201` `ShiftSwapRequest` (`status=PENDING`) | `VALIDATION_FAILED` (past date), `CONFLICT` (assignment locked / already swapped) | +| `POST /shift-swaps/{id}/decide` | `shift_swap:decide` | `{ decision: "APPROVE"\|"REJECT", comment? }` | `200` `ShiftSwapRequest`; APPROVE rewrites both assignments with `source=SWAP` | `NOT_FOUND`, `CONFLICT` | + +### 2.5 Leave + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /leave/types` | `leave:read` | — | `200` `{ data: [LeaveType], meta }` | — | +| `GET /leave/balances?employeeId` | `leave:read` | — | `200` `{ data: [LeaveBalance], meta }` (current `periodYear`) | `NOT_FOUND` | +| `POST /leave/requests` | `leave:request` | see §3.2 | `201` `LeaveRequest` | `VALIDATION_FAILED` (notice/consecutive/attachment/policy), `CONFLICT` (overlap or insufficient balance) | +| `GET /leave/requests?employeeId&status&from&to&pendingForMe&cursor&limit` | `leave:read` | — | `200` `{ data: [LeaveRequest], meta }`; `pendingForMe=true` = approvals inbox | — | +| `POST /leave/requests/{id}/decide` | `leave:approve` | see §3.3 | `200` `LeaveRequest` | `NOT_FOUND`, `CONFLICT` (not pending / not current approver / balance version race) | +| `POST /leave/requests/{id}/cancel` | `leave:request` (self) or `leave:approve` | `{ reason?: string }` | `200` `LeaveRequest` (`status=CANCELLED`, pending/used days released) | `NOT_FOUND`, `CONFLICT` (already terminal; past-dated beyond policy) | + +### 2.6 Payroll + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /payroll/runs?periodYear&status&cursor&limit` | `payroll:read` | — | `200` `{ data: [PayrollRun], meta }` | — | +| `POST /payroll/runs` | `payroll:run` | see §3.6 | `202` `PayrollRun` (`status=CALCULATING`; async via Cloud Tasks) | `VALIDATION_FAILED`, `CONFLICT` (overlapping run for period/branches) | +| `POST /payroll/runs/{id}/approve` | `payroll:approve` | `{ comment?: string }` | `200` `PayrollRun` (`status=APPROVED`, `approvedBy` set; payslips finalize + PDFs render async) | `NOT_FOUND`, `CONFLICT` (status ≠ `REVIEW`) | +| `GET /payslips?employeeId&year&cursor&limit` | `payslip:read` | — | `200` `{ data: [Payslip], meta }` (self-scoped for EMPLOYEE) | — | +| `GET /payslips/{id}` | `payslip:read` | — | `200` `{ data: { …Payslip, lines: [PayslipLine] }, meta: {} }` | `NOT_FOUND` | + +### 2.7 Comms, analytics, audit + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /announcements?activeOnly&cursor&limit` | `announcement:read` | — | `200` list (audience-filtered) | — | +| `POST /announcements` | `announcement:create` | `{ title, body, audienceJson, publishAt, expiresAt?, priority }` | `201` `Announcement` | `VALIDATION_FAILED` | +| `GET /notifications?unreadOnly&cursor&limit` | `notification:read` (self) | — | `200` `{ data: [NotificationMessage], meta }` | — | +| `POST /notifications/{id}/read` | `notification:read` (self) | — | `200` `NotificationMessage` (`readAt` set; idempotent) | `NOT_FOUND` | +| `GET /analytics/kpis?scope&period` | `analytics:read` | `scope`: `company\|branch:{id}\|department:{id}`; `period`: `yyyy-MM` or `yyyy-'W'ww` | `200` `{ data: { headcount, presentRate, lateRate, absenceRate, avgOvertimeMinutes, leaveUtilization, payrollCost? }, meta: {} }` | `VALIDATION_FAILED` | +| `GET /analytics/insights` | `analytics:read` | — | `200` `{ data: [{ kind, severity, subjectType, subjectId, summary, evidenceJson, generatedAt }], meta }` (P4 populates) | — | +| `GET /audit-logs?resourceType&from&to&actorId&cursor&limit` | `audit:read` | — | `200` `{ data: [AuditLog], meta }` | `VALIDATION_FAILED` (range > 92 days) | + +### 2.8 Sync + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `POST /sync/push` | `sync:push` | see §3.4 | `200` per-op results (batch never fails atomically) | `VALIDATION_FAILED` (malformed batch; >100 ops) | +| `GET /sync/pull?types&cursor&limit` | `sync:pull` | `types`: CSV of resourceTypes | `200` see §3.5 | `VALIDATION_FAILED` (unknown type; expired cursor ⇒ client resets cursor and re-pulls) | + +--- + +## 3. Critical flow examples + +### 3.1 `POST /attendance/punches` + +**GPS variant** — headers `Authorization`, `Idempotency-Key: 01J2Q9F1QZJ8M4V0T8B3N7XW5D`: + +```json +{ + "id": "01J2Q9F1QZJ8M4V0T8B3N7XW5D", + "type": "IN", + "method": "GPS", + "punchedAt": "2026-07-17T09:02:11.482Z", + "lat": 25.197197, + "lng": 55.274376, + "accuracyM": 12.4, + "isMock": false, + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "note": null +} +``` + +`201 Created`: + +```json +{ + "data": { + "id": "01J2Q9F1QZJ8M4V0T8B3N7XW5D", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", + "punchedAt": "2026-07-17T09:02:11.482Z", + "type": "IN", + "method": "GPS", + "lat": 25.197197, "lng": 55.274376, "accuracyM": 12.4, + "geofenceId": "01HVQ7R2M5XT8B4WNJ0K6YD3PZ", + "insideFence": true, + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "kioskId": null, "faceScore": null, "photoUrl": null, "note": null, + "serverValidated": true, + "invalidReason": null, + "createdAt": "2026-07-17T09:02:12.010Z", + "updatedAt": "2026-07-17T09:02:12.010Z" + }, + "meta": {} +} +``` + +Failure (`422`, `application/problem+json`): `code: "GEOFENCE_VIOLATION"` as shown in §1.4. Note: tenant policy (`Company.settingsJson`) may instead persist the punch with `serverValidated=false, invalidReason="GEOFENCE_VIOLATION"` and return `201` — the problem response is for the strict-policy default. + +**QR kiosk variant** — same endpoint, token replaces coordinates: + +```json +{ + "id": "01J2QA0C3VKXW8N5T1RD9B6MYF", + "type": "IN", + "method": "QR", + "punchedAt": "2026-07-17T09:03:40.115Z", + "kioskToken": "v1.01HVKQ8SK2M7X4TB9WRC5J0DNP.58913127.Gm4qXcVb9tE2LkAzR7yPwQ1sHj8UfNd3oZ6TeKvB0aY", + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" +} +``` + +`kioskToken` = `v1...` where `window = floor(epochSeconds / 30)`. Server verification: signature, window skew ≤ ±1, kiosk branch == employee branch. Success mirrors the GPS response with `kioskId` set and `lat/lng` null; failure is `422 KIOSK_TOKEN_INVALID`. + +### 3.2 `POST /leave/requests` + +```json +{ + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", + "startDate": "2026-08-03", + "endDate": "2026-08-05", + "startHalf": false, + "endHalf": true, + "reason": "Family travel", + "attachmentUrl": null +} +``` + +`201 Created` — server computed `days` (2.5: three days minus the Aug 5 half, no holidays in range), built the chain, debited `pendingDays`: + +```json +{ + "data": { + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", + "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", + "startDate": "2026-08-03", "endDate": "2026-08-05", + "startHalf": false, "endHalf": true, + "days": 2.5, + "reason": "Family travel", + "attachmentUrl": null, + "status": "PENDING", + "approvalChainJson": [ + { "step": 1, "approverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", "roleCode": "TEAM_LEAD", "decision": null, "decidedAt": null, "comment": null }, + { "step": 2, "approverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "roleCode": "BRANCH_MANAGER", "decision": null, "decidedAt": null, "comment": null } + ], + "currentApproverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", + "decidedAt": null, + "createdAt": "2026-07-17T10:15:03.271Z", + "updatedAt": "2026-07-17T10:15:03.271Z" + }, + "meta": {} +} +``` + +Errors: `400 VALIDATION_FAILED` (`minNoticedays` violated, `maxConsecutiveDays` exceeded, attachment missing while `requiresAttachment`), `409 CONFLICT` (overlapping request, or `pendingDays + usedDays` would exceed balance). + +### 3.3 `POST /leave/requests/{id}/decide` + +```json +{ "decision": "APPROVE", "comment": "Enjoy the trip" } +``` + +`200 OK` (intermediate step — chain advances): + +```json +{ + "data": { + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "status": "PENDING", + "approvalChainJson": [ + { "step": 1, "approverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", "roleCode": "TEAM_LEAD", "decision": "APPROVE", "decidedAt": "2026-07-17T11:40:22.905Z", "comment": "Enjoy the trip" }, + { "step": 2, "approverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "roleCode": "BRANCH_MANAGER", "decision": null, "decidedAt": null, "comment": null } + ], + "currentApproverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", + "decidedAt": null, + "updatedAt": "2026-07-17T11:40:22.905Z" + }, + "meta": {} +} +``` + +When the **final** approver approves: transaction moves `days` from `pendingDays` to `usedDays` on `LeaveBalance` (guarded by `version`), sets `status=APPROVED`, `currentApproverId=null`, `decidedAt`, marks affected `AttendanceDay` rows `LEAVE`, and notifies the employee. A `REJECT` at any step is terminal: `status=REJECTED`, `pendingDays` released. `409 CONFLICT` if the caller is not `currentApproverId` or the request already reached a terminal status. + +### 3.4 `POST /sync/push` + +Batched outbox drain (≤100 ops, FIFO per resourceType). `opId` is the per-op idempotency key (the outbox row's `idempotencyKey`): + +```json +{ + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "ops": [ + { + "opId": "01J2QC1M8TWXV5K2N9RB4D7PYF", + "opType": "CREATE", + "resourceType": "punches", + "resourceId": "01J2QC1M8TWXV5K2N9RB4D7PYF", + "payload": { "type": "OUT", "method": "GPS", "punchedAt": "2026-07-16T18:31:07.220Z", "lat": 25.197201, "lng": 55.274390, "accuracyM": 9.8, "isMock": false, "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" } + }, + { + "opId": "01J2QC2P4VKXW9M3T6RD8B1NYC", + "opType": "CREATE", + "resourceType": "leaveRequests", + "resourceId": "01J2QC2P4VKXW9M3T6RD8B1NYC", + "payload": { "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", "startDate": "2026-09-01", "endDate": "2026-09-01", "startHalf": false, "endHalf": false, "reason": "Medical appointment" } + }, + { + "opId": "01J2QC3R7YWXK4V8N2TB6D9MPF", + "opType": "CREATE", + "resourceType": "punches", + "resourceId": "01J2QC3R7YWXK4V8N2TB6D9MPF", + "payload": { "type": "IN", "method": "GPS", "punchedAt": "2026-07-17T08:59:41.006Z", "lat": 24.991102, "lng": 55.146800, "accuracyM": 8.1, "isMock": false, "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" } + } + ] +} +``` + +`200 OK` — the batch itself always succeeds; each op reports independently: + +```json +{ + "data": { + "results": [ + { "opId": "01J2QC1M8TWXV5K2N9RB4D7PYF", "status": "APPLIED", "resourceType": "punches", "resource": { "id": "01J2QC1M8TWXV5K2N9RB4D7PYF", "serverValidated": true, "insideFence": true, "updatedAt": "2026-07-17T12:00:04.118Z" } }, + { "opId": "01J2QC2P4VKXW9M3T6RD8B1NYC", "status": "REPLAYED", "resourceType": "leaveRequests", "resource": { "id": "01J2QC2P4VKXW9M3T6RD8B1NYC", "status": "PENDING", "days": 1.0, "updatedAt": "2026-07-17T07:44:51.930Z" } }, + { "opId": "01J2QC3R7YWXK4V8N2TB6D9MPF", "status": "REJECTED", "resourceType": "punches", + "problem": { "type": "https://api.worktrack.app/errors/geofence-violation", "title": "Punch outside geofence", "status": 422, "code": "GEOFENCE_VIOLATION", "detail": "Location is 18.4 km from nearest active geofence." } } + ] + }, + "meta": {} +} +``` + +Client contract per master spec §6.3: `APPLIED`/`REPLAYED` ⇒ outbox row `DONE`, local row reconciled (`syncStatus=SYNCED`, server fields win). `REJECTED` ⇒ outbox row `FAILED`, local row flagged, actionable notification raised — never silent loss. Ops for the same `resourceType` are applied in array order. + +### 3.5 `GET /sync/pull?types=attendanceDays,leaveRequests,notifications&cursor=eyJ3IjoiMjAyNi0wNy0xN1QwNzo0NDo1MS45MzBaIn0&limit=200` + +`200 OK`: + +```json +{ + "data": { + "changes": [ + { "type": "attendanceDays", "op": "UPSERT", + "doc": { "id": "01J2QCX0M4TWK8V2N7RB5D9PYA", "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", "date": "2026-07-16", "shiftId": "01HVJ2M8QK4XT6WB9RC3N5D0PZ", "firstInAt": "2026-07-16T08:57:02.310Z", "lastOutAt": "2026-07-16T18:31:07.220Z", "workedMinutes": 514, "breakMinutes": 60, "lateMinutes": 0, "earlyOutMinutes": 0, "overtimeMinutes": 34, "status": "PRESENT", "computedAt": "2026-07-17T12:00:05.402Z", "version": 3, "updatedAt": "2026-07-17T12:00:05.402Z" } }, + { "type": "leaveRequests", "op": "UPSERT", + "doc": { "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", "status": "PENDING", "currentApproverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "days": 2.5, "updatedAt": "2026-07-17T11:40:22.905Z" } }, + { "type": "notifications", "op": "UPSERT", + "doc": { "id": "01J2QD5T9WKXV3M8N4RB7C2PYE", "kind": "LEAVE_STEP_APPROVED", "title": "Leave request update", "body": "Step 1 of 2 approved", "dataJson": { "deepLink": "worktrack://leave/requests/01J2QB7H5PWXK2M9V4TC8N1RDF" }, "readAt": null, "sentAt": "2026-07-17T11:40:23.512Z", "updatedAt": "2026-07-17T11:40:23.512Z" } }, + { "type": "leaveRequests", "op": "TOMBSTONE", "id": "01J1XR8K2MTWV6N9B4C7D5PYQZ", "deletedAt": "2026-07-17T09:12:44.008Z" } + ] + }, + "meta": { "cursor": "eyJ3IjoiMjAyNi0wNy0xN1QxMjowMDowNS40MDJaIiwibiI6InMwN18wMUoyUUNYMCJ9", "hasMore": false } +} +``` + +Changes are ordered by `updatedAt` across the requested types; the cursor is a per-type watermark bundle. The client applies each page in one Room transaction, then persists `meta.cursor` into `sync_cursors`. `TOMBSTONE` deletes the local row. An expired cursor returns `VALIDATION_FAILED` with `errors[0].reason="CURSOR_EXPIRED"`; the client clears the cursor and performs a windowed re-pull (bounded by Room retention windows, `03-database-design.md` §6.1). + +### 3.6 `POST /payroll/runs` + +```json +{ + "id": "01J2QE8V2MKXT7W4N9RB3C6PYD", + "periodYear": 2026, + "periodMonth": 7, + "branchIds": ["01HV7B3M9QKX2T4WRC8N6JD1PZ", "01HV7B4N0RLY3U5XSD9P7KE2QA"] +} +``` + +`202 Accepted` — calculation dispatched to Cloud Tasks; poll `GET /payroll/runs` or await the notification: + +```json +{ + "data": { + "id": "01J2QE8V2MKXT7W4N9RB3C6PYD", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "periodYear": 2026, "periodMonth": 7, + "branchIdsJson": ["01HV7B3M9QKX2T4WRC8N6JD1PZ", "01HV7B4N0RLY3U5XSD9P7KE2QA"], + "status": "CALCULATING", + "startedBy": "01HXPAYADM4T7W2KRB9C3N6QYD", + "approvedBy": null, + "totalsJson": null, + "lockedAt": null, + "createdAt": "2026-07-17T13:05:10.660Z", + "updatedAt": "2026-07-17T13:05:10.660Z" + }, + "meta": {} +} +``` + +The job snapshots `EmployeeSalary` (effective-dated), `AttendanceDay`, and approved leave for the period; writes one `Payslip` + `PayslipLine`s per employee (`status=DRAFT`); fills `totalsJson`; transitions the run to `REVIEW`. `POST /payroll/runs/{id}/approve` then finalizes payslips and renders PDFs. `409 CONFLICT` if a non-`CLOSED` run overlaps the same period and any of the same branches. + +--- + +## 4. Sequence diagrams + +### 4.1 GPS punch validation + +```mermaid +sequenceDiagram + autonumber + participant App as Android App + participant API as API (Cloud Functions) + participant FS as Firestore + participant PS as Pub/Sub + + App->>App: Capture GPS fix + isMock check, mint ULID, write Room (syncStatus=PENDING) + OutboxEntry + App->>API: POST /attendance/punches (Idempotency-Key) + API->>API: Verify ID token -> claims {cid,r,b,eid} + API->>API: RBAC attendance:punch, device binding + Play Integrity gate + API->>FS: Load active geofences (branch), last punch (deviceId) + API->>API: Haversine vs fences, accuracy gate, speed-of-travel plausibility, IN/OUT debounce + alt valid + API->>FS: Write punch (serverValidated=true, insideFence, geofenceId) + API->>PS: Publish day-recompute {employeeId, date} (ordering key = employeeId) + API-->>App: 201 AttendancePunch + PS->>FS: (async, debounced 30s) recompute AttendanceDay, version++ + else geofence violation (strict policy) + API-->>App: 422 problem+json code=GEOFENCE_VIOLATION + App->>App: Outbox FAILED + actionable notification (suggest regularization) + end +``` + +### 4.2 QR kiosk TOTP flow + +```mermaid +sequenceDiagram + autonumber + participant Kiosk as Kiosk Terminal (KIOSK role) + participant Emp as Employee App + participant API as API + participant FS as Firestore + + Kiosk->>Kiosk: Every 30s: window=floor(now/30), sig=HMAC-SHA256(kioskSecret, kioskId+"."+window) + Kiosk->>Kiosk: Render QR = "v1..." + Emp->>Kiosk: Scan QR (ML Kit) + Emp->>API: POST /attendance/punches {method:QR, kioskToken, deviceId} (Idempotency-Key) + API->>API: Verify token, RBAC, device binding gate + API->>FS: Load kiosk device + secret by kioskId + API->>API: Recompute HMAC, check sig + window skew <= +/-1 (90s grace) + API->>API: Kiosk branch == employee branch? + alt token valid + API->>FS: Write punch (method=QR, kioskId, serverValidated=true) + API-->>Emp: 201 AttendancePunch + else invalid signature / stale window / branch mismatch + API-->>Emp: 422 problem+json code=KIOSK_TOKEN_INVALID + Emp->>Emp: Prompt rescan (fresh window) + end +``` + +### 4.3 Leave approval chain + +```mermaid +sequenceDiagram + autonumber + participant Emp as Employee App + participant API as API + participant FS as Firestore + participant TL as Team Lead + participant BM as Branch Manager + + Emp->>API: POST /leave/requests + API->>FS: Load policy, balance, holidays; compute days + API->>FS: TXN: create request (PENDING, chain[TL,BM]), balance.pendingDays += days (version check) + API-->>Emp: 201 LeaveRequest (currentApproverId=TL) + API->>TL: NotificationMessage (deep link worktrack://approvals) + TL->>API: POST /leave/requests/{id}/decide {APPROVE} + API->>FS: Update chain step 1, currentApproverId=BM + API->>BM: NotificationMessage + BM->>API: POST /leave/requests/{id}/decide {APPROVE} + API->>FS: TXN: status=APPROVED, decidedAt; balance.pendingDays -= days, usedDays += days (version check); AttendanceDay(range).status=LEAVE + API->>Emp: NotificationMessage LEAVE_DECIDED + Note over API,FS: Any REJECT is terminal - status=REJECTED, pendingDays released, employee notified +``` + +### 4.4 Offline sync push/pull cycle + +```mermaid +sequenceDiagram + autonumber + participant UI as Compose UI + participant Room as Room (source of truth) + participant SW as SyncWorker (WorkManager) + participant API as API + + UI->>Room: Mutation written optimistically (syncStatus=PENDING) + OutboxEntry(idempotencyKey) + Note over SW: Network-constrained, exponential backoff, unique work + SW->>Room: Drain outbox FIFO per resourceType (state=PENDING -> IN_FLIGHT) + SW->>API: POST /sync/push {ops[<=100]} + API-->>SW: 200 per-op results (APPLIED | REPLAYED | REJECTED+problem) + SW->>Room: DONE + reconcile (server fields win) / FAILED + notification + loop per resourceType cursor + SW->>API: GET /sync/pull?types&cursor + API-->>SW: 200 {changes[UPSERT|TOMBSTONE], meta.cursor, hasMore} + SW->>Room: Apply page in one TXN, advance sync_cursors row + end + Room-->>UI: Flow emissions re-render state +``` + +--- + +## 5. Webhooks (P4 — design sketch) + +Outbound webhooks ship with the open-API program in P4 (master spec §8). Design is fixed now so P0–P3 event producers emit compatible internal events. + +### 5.1 Event catalog + +Event names are `resource.action`, versioned by payload schema (`specversion` per event): + +| Event | Fired when | Payload core | +|---|---|---| +| `employee.created` / `employee.updated` / `employee.deactivated` | Org lifecycle | Employee (PII-minimized: id, employeeCode, org placement, status) | +| `attendance.punch.recorded` | Punch persisted (valid or not) | Punch incl. `serverValidated`, `invalidReason` | +| `attendance.day.computed` | AttendanceDay (re)computed | AttendanceDay | +| `attendance.regularization.decided` | Terminal decision | RegularizationRequest | +| `leave.request.submitted` / `leave.request.decided` / `leave.request.cancelled` | Leave lifecycle | LeaveRequest + delta of balance effect | +| `shift.swap.decided` | Swap approved/rejected | ShiftSwapRequest + affected assignments | +| `payroll.run.status_changed` | Any run transition (`CALCULATING→REVIEW→APPROVED→PAID→CLOSED`) | PayrollRun (totalsJson included from REVIEW) | +| `payslip.finalized` | Payslip goes FINAL | Payslip (no lines; fetch via API) | +| `announcement.published` | `publishAt` reached | Announcement | + +Delivery: per-tenant endpoint registrations with per-event subscriptions; at-least-once via Cloud Tasks with exponential backoff (max 24h, then dead-letter + admin notification); consumers must be idempotent on `eventId` (ULID). + +### 5.2 Envelope and signature + +```json +{ + "eventId": "01JABCXYZ0M4TWK8V2N7RB5DQP", + "event": "leave.request.decided", + "specversion": "1.0", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "occurredAt": "2026-07-17T11:58:00.412Z", + "data": { … } +} +``` + +Headers: + +``` +X-WorkTrack-Event: leave.request.decided +X-WorkTrack-Delivery: 01JABD0FQ2… (unique per attempt) +X-WorkTrack-Timestamp: 1784721480 (unix seconds, signing time) +X-WorkTrack-Signature: v1=hex(HMAC-SHA256(endpointSecret, timestamp + "." + rawBody)) +``` + +Verification rules for consumers: (1) recompute the HMAC over the **raw** body with the shared `endpointSecret` (issued at registration, rotatable with dual-signing overlap `v1=…,v1=…`); (2) constant-time compare; (3) reject if `|now − timestamp| > 300s` (replay protection); (4) dedupe on `eventId`. Failed signature or stale timestamp must return 4xx so the delivery is not retried against a misconfigured secret indefinitely; WorkTrack alerts the tenant admin after 10 consecutive signature failures. diff --git a/docs/05-android-architecture.md b/docs/05-android-architecture.md new file mode 100644 index 0000000..31969a8 --- /dev/null +++ b/docs/05-android-architecture.md @@ -0,0 +1,307 @@ +# WorkTrack — Android App Architecture & Navigation + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§2, §6) + +**Purpose.** This document specifies the Android application architecture for WorkTrack: the Clean Architecture layering and Gradle module graph, the convention-plugin build system, the MVVM/UDF presentation contract, the complete navigation design (routes, arguments, deep links, role gating, state preservation), offline-first behavior per screen, runtime permission handling with the Play Integrity integration point, and the testing strategy. It is binding for all Android code in this repository; deviations require an update to this document and, where applicable, to the master spec first. + +--- + +## 1. Architectural principles + +1. **Clean Architecture, dependency rule inward.** UI depends on domain; domain depends on nothing Android-specific; data implements domain contracts. No feature module ever touches Room, Retrofit, or DataStore directly. +2. **Offline-first.** Room is the single local source of truth (master spec §6.3). Every screen renders from Room `Flow`s; the network only feeds Room via sync, never the UI directly. +3. **Unidirectional data flow (UDF).** State flows down as a single immutable `UiState`; events flow up as a sealed `UiEvent`; one-shot effects are delivered exactly once. +4. **Server-authoritative money paths.** Attendance validity, leave balances, and payslips are read-only projections on the client; the app proposes, the server decides (master spec §3). +5. **Composable isolation.** Screens are stateless; all state hoisting terminates at the ViewModel. This makes every screen previewable, screenshot-testable, and reusable in kiosk mode (P1). + +### 1.1 Layering + +| Layer | Modules | Contents | Allowed dependencies | +|---|---|---|---| +| Feature (UI) | `feature:auth`, `feature:dashboard`, `feature:attendance`, `feature:leave`, `feature:payslips`, `feature:profile` | Compose screens, ViewModels, per-feature nav graphs | `core:domain`, `core:designsystem`, `core:common` | +| Domain | `core:domain` | Use cases, repository **interfaces**, domain policies (e.g. punch eligibility) | `core:model`, `core:common` | +| Data | `core:data` | Repository implementations, mappers, offline write pipeline (Room + outbox enqueue) | `core:database`, `core:network`, `core:datastore`, `core:domain`, `core:model` | +| Data sources | `core:database` (Room), `core:network` (Retrofit/OkHttp), `core:datastore` (Proto DataStore) | DAOs/entities, API services/DTOs, preferences | `core:model`, `core:common` | +| Sync | `core:sync` | WorkManager workers, outbox processor, cursor pull, scheduling | `core:data` | +| Cross-cutting | `core:model` (entities/value types), `core:common` (`Result`, dispatchers, time/Clock abstraction), `core:designsystem` (M3 theme + components) | — | `core:model` → nothing; `core:common` → nothing | + +`app` composes everything: root `NavHost`, main scaffold, Hilt application, WorkManager initialization, deep-link intent filters. + +## 2. Gradle module graph + +Exactly the graph from master spec §6.1: + +```mermaid +graph TD + app --> fauth[feature:auth] + app --> fdash[feature:dashboard] + app --> fatt[feature:attendance] + app --> fleave[feature:leave] + app --> fpay[feature:payslips] + app --> fprof[feature:profile] + app --> sync[core:sync] + app --> data[core:data] + + fauth --> domain[core:domain] + fdash --> domain + fatt --> domain + fleave --> domain + fpay --> domain + fprof --> domain + fauth --> ds[core:designsystem] + fdash --> ds + fatt --> ds + fleave --> ds + fpay --> ds + fprof --> ds + fauth --> common[core:common] + fdash --> common + fatt --> common + fleave --> common + fpay --> common + fprof --> common + + sync --> data + data --> db[core:database] + data --> net[core:network] + data --> dstore[core:datastore] + data --> domain + data --> model[core:model] + domain --> model + domain --> common + db --> model + db --> common + net --> model + net --> common + dstore --> model + dstore --> common +``` + +Rules enforced in CI (dependency-guard / `checkModuleGraph` task): + +- `feature:*` may not depend on `core:data`, `core:database`, `core:network`, `core:datastore`, `core:sync`, or another `feature:*`. +- `core:domain` has zero Android framework dependencies (pure Kotlin/JVM module; `SavedStateHandle` and `Flow` types come from KMP-safe artifacts only). +- Only `app` depends on `core:sync`; features trigger sync through the `SyncRequester` interface in `core:domain`, implemented in `core:sync` and bound in `app`. +- `core:designsystem` contains no business logic and no ViewModels. + +## 3. Build logic — convention plugins + +All build configuration lives in `build-logic/` as composite-build convention plugins (master spec §6.1): + +| Plugin id | Applies to | Provides | +|---|---|---| +| `worktrack.android.application` | `app` | AGP application config, SDK levels (min 26 / target latest stable), signing config plumbing, R8 rules, build types (`debug`, `benchmark`, `release`) | +| `worktrack.android.library` | all `core:*` Android modules | AGP library config, Kotlin 2.x compiler options (`-Xjvm-default=all`, explicit API mode for `core:domain`/`core:model`), lint baseline | +| `worktrack.android.library.compose` | `core:designsystem`, any library with UI | Compose compiler wiring, compose BOM, metrics/reports flags | +| `worktrack.android.feature` | all `feature:*` | = library + compose + hilt + default deps on `core:domain`, `core:designsystem`, `core:common`, navigation-compose, lifecycle | +| `worktrack.android.hilt` | any module with DI | Hilt + KSP wiring | +| `worktrack.android.room` | `core:database` | Room + KSP, schema export dir (`schemas/`, checked in for migration tests) | + +Why convention plugins rather than `subprojects {}` blocks or shared `.gradle` scripts: + +1. **Single point of change.** SDK bump, Kotlin upgrade, or a new lint rule is one edit in `build-logic`, not 16 build files. +2. **Type-safe and testable.** Plugins are Kotlin classes; misconfiguration fails compilation of `build-logic`, not a runtime surprise mid-build. +3. **Feature-module cost is near zero.** A new feature's `build.gradle.kts` is ~5 lines (`id("worktrack.android.feature")` + one namespace), which keeps the module graph honest — nobody skips modularization because setup is tedious. +4. **Configuration-cache and build-scan friendly.** No cross-project configuration; every module is isolated, enabling parallel configuration and remote build cache hits. + +Versions are centralized in `gradle/libs.versions.toml`; convention plugins read the catalog, so modules never declare raw coordinates. + +## 4. Presentation contract (MVVM + UDF) + +Every screen follows one contract, with no exceptions: + +```kotlin +// 1. Single immutable state — the only thing the screen renders. +data class LeaveApplyUiState( + val leaveTypes: List = emptyList(), + val balances: Map = emptyMap(), + val form: LeaveFormUi = LeaveFormUi(), + val submitInProgress: Boolean = false, + val isOffline: Boolean = false, + val error: UiText? = null, +) + +// 2. Sealed events — the only way the screen talks to the ViewModel. +sealed interface LeaveApplyEvent { + data class TypeSelected(val leaveTypeId: String) : LeaveApplyEvent + data class DatesChanged(val start: LocalDate, val end: LocalDate) : LeaveApplyEvent + data object Submit : LeaveApplyEvent +} + +// 3. One-shot effects — navigation, snackbars, system dialogs. +sealed interface LeaveApplyEffect { + data class NavigateToDetail(val requestId: String) : LeaveApplyEffect + data class ShowSnackbar(val message: UiText) : LeaveApplyEffect +} + +@HiltViewModel +class LeaveApplyViewModel @Inject constructor( + private val applyLeave: ApplyLeaveUseCase, + observeLeaveTypes: ObserveLeaveTypesUseCase, + observeBalances: ObserveLeaveBalancesUseCase, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + val uiState: StateFlow = /* combine(...).stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), LeaveApplyUiState()) */ + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + fun onEvent(event: LeaveApplyEvent) { /* ... */ } +} +``` + +Contract rules: + +- **One `StateFlow` per ViewModel.** No secondary `LiveData`, no exposed `MutableStateFlow`, no per-field flows. `stateIn(WhileSubscribed(5_000))` so upstream Room flows stop when the screen leaves composition (survives rotation without restart). +- **Effects via `Channel(BUFFERED).receiveAsFlow()`**, collected in the screen with `LaunchedEffect` + `repeatOnLifecycle(STARTED)`. Effects are for things that must happen exactly once (navigate, snackbar, permission launch). Anything renderable belongs in `UiState` instead. +- **Screens are stateless composables**: `LeaveApplyScreen(state: LeaveApplyUiState, onEvent: (LeaveApplyEvent) -> Unit)`. A thin `LeaveApplyRoute` composable owns the ViewModel, collects state with `collectAsStateWithLifecycle()`, and wires effects to the `NavController`/`SnackbarHostState`. Only `*Route` composables may reference a ViewModel. +- **Form/transient input survives process death** via `SavedStateHandle` (see §5.5); domain data never does — it re-materializes from Room. +- **Loading is modeled, not implied.** `UiState` uses explicit sub-states (`isOffline`, `submitInProgress`, `error: UiText?`); no screen infers loading from null. +- **`UiText`** wraps string resources vs. raw server strings so composables stay context-free and testable. + +Use cases in `core:domain` are single-verb classes (`ApplyLeaveUseCase`, `RecordPunchUseCase`, `ObserveAttendanceDaysUseCase`) with `operator fun invoke`. Commands return `Result` from `core:common`; observations return `Flow`. ViewModels never call repositories directly. + +## 5. Navigation + +Root structure per master spec §6.2: `AuthGraph` (Login → ForgotPassword → DeviceBinding) → `MainGraph` with a bottom-bar scaffold (**Dashboard**, **Attendance**, **Leave**, **Profile**) and nested destinations. + +### 5.1 Route table + +Routes are defined as type-safe `@Serializable` destinations (Navigation-Compose 2.8+); the patterns below are the canonical string forms and deep-link URIs. + +| Route pattern | Args | Deep link | Entry points | Role gating | +|---|---|---|---|---| +| `auth/login` | — | — | App start (unauthenticated) | none | +| `auth/forgot-password` | — | — | Login | none | +| `auth/device-binding` | — | — | Post-login when no bound `Device` for this install | authenticated, pre-main | +| `main/dashboard` | — | — | Bottom bar (start destination) | any authenticated | +| `main/attendance` | — | — | Bottom bar; dashboard punch card | any authenticated | +| `main/attendance/history?from={date}&to={date}` | `from`, `to` optional ISO dates | — | Attendance hub; dashboard "this week" card | self only | +| `main/attendance/punch?method={method}` | `method ∈ {GPS, QR}` (FACE in P1) | — | Attendance hub CTA; dashboard quick action | `attendance:punch` (all employees) | +| `main/leave` | — | — | Bottom bar | any authenticated | +| `main/leave/apply` | — | — | Leave hub CTA | `leave:request` | +| `main/leave/requests/{requestId}` | `requestId` (ULID) | `worktrack://leave/requests/{id}` | Leave list; push notification; approvals inbox | self, or approver on the request's chain | +| `main/approvals` | — | `worktrack://approvals` | Dashboard badge card; push notification | any of `TEAM_LEAD`, `BRANCH_MANAGER`, `HR_ADMIN`, `COMPANY_ADMIN` (client mirror of `leave:approve` / `attendance:approve`) | +| `main/payslips` | — | — | Profile section; dashboard card | `payroll:read-self` | +| `main/payslips/{payslipId}` | `payslipId` (ULID) | `worktrack://payslips/{id}` | Payslip list; push notification | owner of the payslip | +| `main/announcements` | — | — | Dashboard feed "see all"; notification | any authenticated | +| `main/profile` | — | — | Bottom bar | any authenticated | +| `main/settings` | — | — | Profile top-bar action | any authenticated | + +Deep-link handling: `app` declares the `worktrack://` scheme intent filter. On cold start, `MainActivity` hands the intent to the `NavHost`; if the session is invalid the pending destination is stored in `SavedStateHandle` of the auth flow and replayed after login + device binding. Role-gated deep links (e.g. `worktrack://approvals` sent to an `EMPLOYEE` whose lead role was revoked) resolve to Dashboard with an explanatory snackbar — the server remains the enforcement point; client gating is UX only (master spec §1.1). + +### 5.2 Nav graph + +```mermaid +flowchart TD + subgraph AuthGraph + Login[auth/login] --> Forgot[auth/forgot-password] + Login -->|"authenticated, unbound device"| Bind[auth/device-binding] + end + Bind -->|"bound (POST /devices ok)"| Dash + Login -->|"authenticated + bound"| Dash + + subgraph MainGraph [MainGraph — bottom-bar scaffold] + Dash[main/dashboard] + Att[main/attendance] + Leave[main/leave] + Prof[main/profile] + + Att --> Hist[attendance/history] + Att --> Punch["attendance/punch (GPS/QR)"] + Leave --> Apply[leave/apply] + Leave --> LDetail["leave/requests/{id}"] + Dash --> Appr[approvals inbox] + Appr --> LDetail + Prof --> Pay[payslips] + Pay --> PDetail["payslips/{id}"] + Dash --> Ann[announcements] + Prof --> Set[settings] + end + + Prof -->|"logout / revoked"| Login +``` + +`AuthGraph` and `MainGraph` are separate nested graphs on the root `NavHost`. Successful auth executes `navigate(MainGraph) { popUpTo(AuthGraph) { inclusive = true } }` so back never returns to Login. Session revocation (401 with terminal reason from `GET /me`, or Firebase token revoked) clears Room user-scoped tables, cancels sync work, and pops to `AuthGraph` the same way in reverse. + +### 5.3 Bottom bar behavior + +- Visible only for the four top-level destinations (`dashboard`, `attendance`, `leave`, `profile`); hidden on all nested destinations (punch flow, detail screens) via `currentBackStackEntryAsState()` route matching. +- Tab switch uses the standard M3 pattern: `navigate(tab) { popUpTo(navController.graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true }` — each tab keeps an independent back stack; re-selecting the current tab pops that tab's stack to its root. +- Approvals inbox is **not** a tab; it is reached from the Dashboard approvals card (badge shows pending count from Room) and via deep link, keeping the bar identical for all roles. +- System back on a tab root (other than Dashboard) returns to Dashboard; back on Dashboard exits the app. + +### 5.4 State preservation + +- Tab back stacks: `saveState`/`restoreState` as above; Compose `rememberSaveable` preserves scroll positions (`LazyListState`) and expanded/collapsed UI within stops. +- ViewModels use `SharingStarted.WhileSubscribed(5_000)` so configuration changes never re-trigger loads; Room flows re-attach instantly with the last cached emission. + +### 5.5 Process death (SavedStateHandle) + +| Concern | Mechanism | +|---|---| +| Current destination + back stacks | Navigation-Compose saves the nav state to the Activity's saved instance state automatically | +| In-progress form input (leave apply dates/reason, regularization note, search queries) | ViewModel writes each field to `SavedStateHandle` keys on change; state builder reads `savedStateHandle.getStateFlow(key, default)` and combines it with Room flows | +| In-flight punch | Never held in memory only: `RecordPunchUseCase` writes Room + `OutboxEntry` transactionally *before* any UI acknowledgment, so process death after tap loses nothing (see doc `08-sync-strategy.md` §3) | +| Pending deep link during auth | Stored in `SavedStateHandle` of `AuthGraph`'s shared back-stack entry, replayed post-binding | +| Domain data | Never saved to instance state — re-materializes from Room; instance state stays under the transaction size budget | + +## 6. Offline-first behavior per screen + +Master spec §6.3 governs; per-screen specifics: + +| Screen | Renders from Room | Requires network | Offline mutation pattern | +|---|---|---|---| +| Dashboard | Today's `AttendanceDay`, own punches, `LeaveBalance`, latest `Announcement`s, pending approvals count | No — fully cached; freshness label shows `lastSyncedAt` when stale > 15 min | n/a (read-only) | +| Punch — GPS | Shift context (`ShiftAssignment`), geofences for the employee's branch | No for capture; GPS fix is local. `insideFence` computed on-device against cached `Geofence` rows | **Optimistic punch**: insert `AttendancePunch(serverValidated=false)` + outbox entry in one Room transaction; UI confirms immediately with "Recorded — will verify when online" chip; `serverValidated`/`invalidReason` reconcile on push ack. Punches are append-only: no local edit/delete ever | +| Punch — QR kiosk | Kiosk scan UX | **Yes** (soft requirement): the TOTP QR window is 30 s, so validation is near-real-time; offline QR punches are still queued, and the server accepts tokens within a bounded clock-skew grace, else rejects with actionable notification | Same optimistic insert; higher rejection probability is surfaced up front ("QR punches need connectivity soon") | +| Attendance history | `AttendanceDay` + punches for range | No; pull-to-refresh triggers expedited sync | Regularization request (P1) follows the leave-apply pattern | +| Leave hub / balances | `LeaveType`, `LeaveBalance`, own `LeaveRequest`s | No | — | +| Leave apply | Types, balances, holiday calendar for date validation | No to submit | **Optimistic apply**: insert `LeaveRequest(status=PENDING, syncStatus=PENDING)` + outbox entry; balance shows a local `pendingDays` overlay clearly marked "pending sync"; server rejection (e.g. stale balance) flips the row to `REJECTED` with reason and raises a notification — never silent (master spec §6.3.6) | +| Leave detail | Request row + approval chain JSON | No | Cancel = optimistic status change + outbox op | +| Approvals inbox | Pending `LeaveRequest`/`RegularizationRequest` where user is `currentApproverId` | No to view; decisions queue offline | Decide = optimistic status + outbox `decide` op; conflicting decision (someone else decided first) is server-rejected and reconciled with a notification | +| Payslips list/detail | `Payslip` + `PayslipLine` rows | PDF download (`pdfUrl`) requires network; cached after first fetch | n/a — payslips are server-authoritative, read-only | +| Announcements | `Announcement` rows | No | Read receipts queue via outbox (`POST /notifications/{id}/read`) | +| Profile / settings | `Employee` row, bound `Device` | Avatar upload requires network | Editable profile fields: optimistic Room update + outbox; last-write-wins on the server for these fields (doc 08 §6) | +| Auth / device binding | — | **Yes** — Firebase Auth and `POST /devices` are online-only by design | n/a | + +Global rules: + +- A persistent, non-blocking offline indicator (top of scaffold) appears when connectivity is lost; screens never block on it. +- `syncStatus` renders as a subtle per-row glyph (pending ⟳ / failed ⚠) on user-owned mutable rows; tapping a failed row shows the error and a retry action. +- No screen issues a direct network call for domain data. The only non-sync network calls are Firebase Auth, device binding, file transfers (avatar, payslip PDF, leave attachment), and Play Integrity. + +## 7. Permissions & Play Integrity + +### 7.1 Runtime permissions + +| Permission | Feature | Strategy | +|---|---|---| +| `ACCESS_FINE_LOCATION` (+ `ACCESS_COARSE_LOCATION` fallback) | GPS punch, geofence check | Requested **in-context** on first GPS punch attempt, never at onboarding. Pre-request rationale sheet explains: location is captured only at the moment of punching, never tracked in background. If the user selects "approximate only" (Android 12+), the punch flow explains that fine accuracy is required for geofence validation and offers the settings shortcut; a coarse-only punch is still recorded but flagged (`accuracyM` high) for server-side review rather than blocked. Permanent denial → punch method selector hides GPS with an inline explanation and offers QR | +| `CAMERA` | QR kiosk scan; face verification capture (P1) | Requested when the user opens the QR scanner. Rationale: "camera is used only to scan the kiosk code / verify it's you; images are processed on-device" (face capture handling per doc `07-security-architecture.md` §6.6) | +| `POST_NOTIFICATIONS` (API 33+) | Approvals, sync rejection alerts, announcements | Requested after first successful login, from a dismissible dashboard card explaining what notifications carry. Denial degrades to in-app notification center only (`GET /notifications` data still syncs) | + +Implementation: a single `PermissionGate` composable in `core:designsystem` renders rationale → system dialog → denial fallback as a state machine, driven by an effect from the ViewModel (`RequestPermission` effect) so permission flows stay testable. No background location is ever requested; the manifest never includes `ACCESS_BACKGROUND_LOCATION`. + +### 7.2 Play Integrity integration point + +- `IntegrityTokenProvider` (interface in `core:domain`, implementation in `core:data` wrapping the Play Integrity **standard request** API) warms up a token provider at app start and produces a token on demand. +- `RecordPunchUseCase` requires an integrity token: the token (or a structured `UNAVAILABLE` marker with reason) is stored on the `OutboxEntry` payload for the punch and sent to `POST /attendance/punches`; the server decodes the verdict and persists it on the `Device`/punch (master spec §7). Tokens are bound to a server-issued nonce fetched during device binding and rotated on each sync session to prevent replay. +- Device binding (`POST /devices`) sends the first integrity verdict; the server may refuse binding on `MEETS_NO_INTEGRITY`. Client behavior on failure is defined in doc `07-security-architecture.md` §6.2 — the app degrades to "punch recorded, subject to review", never hard-crashes on Integrity API unavailability (e.g. no Play Services). +- Mock-location detection: `Location.isMock` (API 31+; `isFromMockProvider` before) is captured per GPS fix and transmitted with the punch payload; detection is advisory client-side, enforced server-side. + +## 8. Testing strategy + +| Level | Scope | Tooling | Gate | +|---|---|---|---| +| Unit — domain | Use cases, policies (punch eligibility, leave day counting incl. half-days/holidays) | JUnit5, kotlinx-coroutines-test, fake repositories | PR-blocking; ≥ 90% line coverage in `core:domain` | +| Unit — ViewModels | State reduction, event handling, effect emission | **Turbine** for `uiState`/`effects` flows; `MainDispatcherRule`; `SavedStateHandle` restoration cases (create VM with pre-seeded handle, assert form state) | PR-blocking | +| Room DAO | Every DAO query, migrations | `Room.inMemoryDatabaseBuilder` under Robolectric for query tests; `MigrationTestHelper` against checked-in `schemas/` for every schema bump; FIFO ordering + transaction atomicity tests for outbox DAO | PR-blocking; a schema change without a migration test fails CI | +| Screenshot | Every `core:designsystem` component and each feature screen's canonical states (loading/empty/error/content, light/dark, small+large font scale, en + one RTL locale) | **Paparazzi** (JVM, no emulator); golden images checked in; `verifyPaparazzi` in CI, `recordPaparazzi` to update with review | PR-blocking on pixel diff | +| Sync end-to-end | Full outbox → push → pull → reconcile loop | JVM integration tests in `core:sync`: real Room (in-memory) + real OkHttp against **MockWebServer scripted as a fake WorkTrack server** (idempotency-key replay returns same result; 409 conflict; 422 rejection; 500 then success for backoff). Scenarios: offline punch burst then reconnect; duplicate delivery; leave apply rejected on stale balance surfaces notification; cursor resume after crash mid-pull | PR-blocking | +| Instrumented smoke | Auth → bind → punch → leave apply happy path on emulator | Compose UI tests + Hilt test app, `TestDispatcher`-driven WorkManager (`WorkManagerTestInitHelper`) | Nightly + release-blocking | + +Cross-cutting conventions: + +- Fakes over mocks for repositories and data sources (fakes live beside the interfaces in `testFixtures`); Mockito/MockK only for platform seams (Integrity, location client). +- Deterministic time everywhere via `core:common`'s `Clock` abstraction — no `System.currentTimeMillis()` outside `core:common`. +- Flaky-test policy: a test that flakes twice in a week is quarantined with an owning ticket; quarantine list must be empty for a release branch cut. diff --git a/docs/06-web-admin-design.md b/docs/06-web-admin-design.md new file mode 100644 index 0000000..a9559ef --- /dev/null +++ b/docs/06-web-admin-design.md @@ -0,0 +1,297 @@ +# WorkTrack — Web Admin Console Design + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§1.1, §2, §5, §8 Phase P3) · Companion: `07-security-architecture.md` + +**Purpose.** This document specifies the WorkTrack Web Admin Console: a React 18 + TypeScript single-page application served from Firebase Hosting that consumes the same versioned REST API (`https://api.worktrack.app/v1`) as the Android app. It defines the information architecture and role-based navigation for admin personas, screen-by-screen functional specs, and the frontend engineering standards — state management, RBAC-driven UI gating, large-table virtualization, optimistic update policy, accessibility, and internationalization. Implementation is roadmap Phase P3; this design is final and binding for that phase. + +--- + +## 1. Platform and stack + +| Concern | Decision | +|---|---| +| Framework | React 18 + TypeScript (strict), Vite build, SPA with client-side routing (React Router) | +| Hosting | Firebase Hosting; `/**` rewrite to `index.html`; immutable hashed assets; API is **not** proxied — the SPA calls `https://api.worktrack.app/v1` directly with CORS | +| Identity | Firebase Auth Web SDK (same tenant claims `{ cid, r, b, eid }` as Android); ID token attached as `Authorization: Bearer` by a fetch wrapper that refreshes via the SDK before expiry | +| Server state | TanStack Query v5 (see §5) | +| URL state | Route params + search params as the single source of truth for filters, pagination cursors, selected entities, wizard steps | +| Client state | Minimal: a small Zustand store for session context (claims, permission set, feature flags) and UI chrome (sidebar collapsed, density); everything else is server or URL state | +| Design system | WorkTrack Web DS: token-compatible with the Android M3 theme (same color roles, type ramp, spacing scale); components built on Radix primitives for accessibility | +| Errors | RFC 7807 `problem+json` parsed centrally; `type` mapped to user-facing messages and remediation hints | +| Testing | Vitest + React Testing Library (components), MSW fake API (integration), Playwright (E2E per persona), axe-core in CI | + +The SPA is **online-only** (admin workflows are connectivity-assumed); TanStack Query caching provides resilience to transient failures, but there is no outbox/offline mode — that is an Android-only contract (master spec §6.3). + +## 2. Personas and information architecture + +Admin console personas (master spec §1.1): `COMPANY_ADMIN`, `HR_ADMIN`, `PAYROLL_ADMIN`, `BRANCH_MANAGER`, `AUDITOR`. (`SUPER_ADMIN` uses an internal ops console outside this document; `EMPLOYEE`/`TEAM_LEAD`/`KIOSK` do not sign in here — the console rejects sessions holding none of the admin roles.) + +### 2.1 Sidebar navigation tree + +``` +Dashboard +Org + ├── Branches + ├── Departments + └── Positions +Employees + ├── Directory + └── Onboarding (P2: checklists) +Attendance + ├── Live Board + ├── Exceptions Queue + └── Regularizations +Rosters + ├── Planner + └── Swap Requests +Leave + ├── Approvals + ├── Requests + └── Balances +Payroll + ├── Runs + ├── Payslips + └── Salary Structures +Announcements +Documents +Audit +Settings + ├── Company Profile + ├── Branches & Geofences + ├── Shifts + ├── Leave Policies + ├── Salary Components + ├── Holiday Calendars + └── Roles & Permissions +``` + +### 2.2 Role → navigation visibility matrix + +Visibility mirrors the server permission catalog (`07-security-architecture.md` §4); the sidebar renders only sections for which the session holds at least one required permission. ✔ = full, ◐ = scoped/partial, — = hidden. + +| Section | COMPANY_ADMIN | HR_ADMIN | PAYROLL_ADMIN | BRANCH_MANAGER | AUDITOR | +|---|---|---|---|---|---| +| Dashboard | ✔ | ✔ | ✔ (payroll KPIs) | ◐ own branches | ✔ read-only | +| Org | ✔ | ✔ | — | ◐ read own branches | ✔ read-only | +| Employees | ✔ | ✔ | ◐ read + salary tab | ◐ own branches, no salary | ✔ read-only, no salary | +| Attendance | ✔ | ✔ | ◐ read (payroll inputs) | ◐ own branches | ✔ read-only | +| Rosters | ✔ | ✔ | — | ◐ own branches (primary user) | ✔ read-only | +| Leave | ✔ | ✔ | ◐ read (LOP inputs) | ◐ own branches | ✔ read-only | +| Payroll | ✔ | ◐ inputs only, no approve | ✔ | — | ✔ read-only | +| Announcements | ✔ | ✔ | — | ◐ own-branch audience | ✔ read-only | +| Documents | ✔ | ✔ | — | ◐ own branches | ✔ read-only | +| Audit | ✔ | ◐ own actions area | ◐ payroll resources | — | ✔ (primary user) | +| Settings | ✔ | ◐ leave policies, holidays | ◐ salary components | — | ✔ read-only | + +`BRANCH_MANAGER` scoping: every list/query the console issues for a branch-scoped session carries `branchId` filters constrained to the `b` claim; the server re-enforces regardless (scope narrowing, `07-security-architecture.md` §4.3). `AUDITOR` sees read-only variants of every screen: all mutating controls are removed (not merely disabled), and export actions are audit-logged. + +### 2.3 Global chrome + +- **Top bar**: company switcher (only for users holding roles in multiple companies — re-authenticates to swap `cid` claims), global search (employees by name/code — `GET /employees?q=`), notification bell (`GET /notifications`), session menu. +- **Breadcrumbs** on every screen below the top level; entity IDs in breadcrumbs are copyable ULIDs. +- **Environment banner** on non-production origins. + +## 3. Screen specifications + +Every screen defines the four canonical states. Unless overridden below: **Loading** = skeleton matching final layout (no spinners for > 300 ms content, no layout shift); **Empty** = illustration + one-line explanation + primary CTA (hidden if the user lacks the CTA permission); **Error** = inline problem card with `problem+json` title, correlation id, and Retry (refetch); table row-level failures never blank the whole screen. + +### 3.1 Analytics dashboard (`/dashboard`) + +- **Purpose**: at-a-glance workforce health for the persona's scope; entry point to exceptions needing action. +- **Data**: `GET /analytics/kpis?scope&period`, `GET /analytics/insights`, pending counts from `GET /leave/requests?status=PENDING&limit=1` (meta count) and attendance exceptions. +- **Components**: KPI stat row (headcount, present today, absent, on leave, late %, pending approvals, payroll days-to-cutoff); trend charts (attendance % 30d, overtime minutes by branch, leave consumption vs accrual); AI insights panel (absenteeism risk, overtime anomaly, attrition signals — each card links to the filtered underlying list and carries a "why am I seeing this" explainer); action queue (top 5 approvals inline-decidable). +- **Primary actions**: period selector (URL param `?period=`), scope selector (company/branch — gated by role), drill-through to filtered screens. +- **States**: per-widget loading/error isolation (one failed widget shows a compact retry card, the rest render); empty insights = "No anomalies detected for this period". + +### 3.2 Employee directory (`/employees`) + profile (`/employees/:employeeId`) + +- **Purpose**: find, inspect, and manage the employee lifecycle. +- **Directory**: virtualized table (§6) over `GET /employees` (cursor pagination, server filters: branch, department, position, status, employmentType, `q`). Columns: code, name+avatar, branch, department, position, status chip, joinDate. Toolbar: filters (all in URL), column chooser, CSV export (server-side job for > 10k rows), "Add employee" (`employee:create`). Row click → profile. Bulk select → assign shift, move branch (each a confirmed batch mutation with per-row result report). +- **Profile tabs**: Overview (identity, org placement, manager chain), Attendance (embedded `GET /attendance/days?employeeId&from&to` month grid), Leave (balances `GET /leave/balances?employeeId` + request history), Payroll (visible only with `payroll:read` — `EmployeeSalary` history + payslips), Documents (`EmployeeDocument` list, upload/verify), Devices (bound devices, revoke via `DELETE /devices/{id}`), Roles (RoleAssignments — `role:assign` only). +- **Primary actions**: edit profile, deactivate (`POST /employees/{id}/deactivate` with exit-date dialog and downstream-impact summary: open approvals, roster slots, payroll inclusion), reset device binding. +- **States**: directory empty = onboarding CTA "Import employees" ; profile 404 = "Employee not found in {company}" with back-to-directory. + +### 3.3 Roster planner (`/rosters/planner?branchId&week`) + +- **Purpose**: build and publish weekly shift rosters per branch (primary `BRANCH_MANAGER` surface). +- **Data**: `GET /rosters?branchId&from&to` (roster grid), `GET /shifts` (palette), employee list for the branch. +- **Components**: week grid — rows = employees (virtualized), columns = 7 days; cell = `ShiftAssignment` chip (shift code + color, `source` glyph for ROSTER/ROTATION/MANUAL/SWAP); left panel shift palette; drag-and-drop assign/move/copy (keyboard equivalent: cell focus + palette picker, §7); conflict badges computed client-side and re-validated server-side (double assignment, leave overlap, night-shift rest-period rule); coverage footer per day (assigned vs required headcount); copy-last-week; unpublished-changes tray. +- **Primary actions**: edit cells (buffered locally), **Publish** = single `PUT /rosters?branchId&from&to` with the week's assignment set and `Idempotency-Key`; discard draft. Publish is blocked while hard conflicts exist. +- **States**: unpublished-draft banner with count; publish partial failure → per-cell error markers and the response's problem detail; week with no roster = "Start from shift rotation" / "Copy previous week" CTAs; roster locked (Cloud Scheduler lock, master spec §2) = read-only banner with lock timestamp. + +### 3.4 Attendance monitoring (`/attendance/live`, `/attendance/exceptions`) + +- **Live Board**: near-real-time presence for the selected scope. Data: `GET /attendance/days?from=today&to=today` + recent `GET /attendance/punches`, polled every 60 s (TanStack Query `refetchInterval`; no websockets in P3). Components: status summary chips (present/absent/late/on-leave/not-yet-in vs shift), virtualized employee grid with last punch time/method/insideFence flag, branch/shift filters, punch-detail drawer (map snippet with punch point vs geofence circle, method, device, `serverValidated`, `invalidReason`). Read-only; `attendance:read` scope-filtered. +- **Exceptions Queue**: actionable list of invalid or suspicious records: punches with `serverValidated=false` or `invalidReason` set (out-of-fence, integrity failure, mock location, speed-of-travel — `07-security-architecture.md` §6), missing OUT punches, `AttendanceDay.status=PENDING`. Grouped by exception type; each row: employee, timestamp, evidence panel, actions **Approve as valid** / **Reject** / **Request regularization** (each `attendance:approve`, each writes an audit log). Bulk approve limited to same exception type ≤ 50 rows. +- **Regularizations** (`/attendance/regularizations`): pending `RegularizationRequest` list → detail with requested vs recorded times diff → `POST /attendance/regularizations/{id}/decide`. +- **States**: live board outside working hours = subdued "No active shifts right now"; exception queue empty = positive empty state ("No exceptions — everything checks out"); poll failure = stale-data banner with last-updated timestamp, board keeps rendering cached data. + +### 3.5 Leave approvals (`/leave/approvals`) + +- **Purpose**: decide pending leave requests at company/branch scope (multi-level chains). +- **Data**: `GET /leave/requests?status=PENDING` (+ scope filters); decision via `POST /leave/requests/{id}/decide`. +- **Components**: queue list (requester, type chip with `colorHex`, dates + day count incl. half-day glyphs, waiting-since, chain position "step 2 of 3"); detail drawer: reason, attachment viewer (`requiresAttachment` types), requester's balance snapshot (`LeaveBalance` incl. `pendingDays`), team-coverage calendar for the request window (who else is off), policy verdict panel (notice period, max consecutive, balance sufficiency — server-computed, surfaced verbatim); approve/reject with mandatory comment on reject. +- **Primary actions**: decide single; bulk approve (only requests with green policy verdicts, ≤ 25); reassign approver (`COMPANY_ADMIN`/`HR_ADMIN`). +- **States**: decision conflict (already decided elsewhere / on mobile) → 409 handled by removing the row with an info toast, never double-applying; empty = "Queue clear". + +### 3.6 Payroll run wizard (`/payroll/runs/new`, resumable at `/payroll/runs/:runId`) + +Five steps mapped to `PayrollRun.status` (`DRAFT → CALCULATING → REVIEW → APPROVED → PAID|CLOSED`); the wizard is resumable — reopening a run routes to the step implied by its status. Step state lives in the URL (`?step=`) and the run resource, never in component memory. + +| Step | Name | Contents | Exit criteria | +|---|---|---|---| +| 1 | **Scope** | Period (year/month), branch multi-select (`branchIds`), included-employee preview count with exclusions list (joined mid-period, exited, missing `EmployeeSalary`) | `POST /payroll/runs` creates DRAFT | +| 2 | **Inputs** | Readiness checklist: attendance days finalized (no `PENDING` in period), leave/LOP applied, overtime totals, unapproved regularizations blocking; per-item drill-through links; ad-hoc input adjustments (bonus/deduction rows) | All blocking checks green or explicitly waived (`payroll:run`, waiver audited) | +| 3 | **Calculate** | Triggers async calculation (Cloud Tasks, master spec §5); progress panel polls run status while `CALCULATING` (N of M payslips); cancel returns to DRAFT | Server sets REVIEW | +| 4 | **Review** | Totals vs previous period (gross/net/deductions variance % with configurable alert threshold), per-employee payslip table (virtualized) with drill-in to `PayslipLine`s, anomaly flags (net < 0, > X% swing, missing components), recalculate-subset action | Reviewer marks reviewed | +| 5 | **Approve** | Summary card, mandatory re-authentication (recent Firebase sign-in), typed confirmation of period, `POST /payroll/runs/{id}/approve`; post-approve: payslip publication + PDF generation status, mark PAID | Run APPROVED; wizard becomes read-only record | + +- **RBAC**: steps 1–4 require `payroll:run`; step 5 requires `payroll:approve`, and the approver must differ from `startedBy` (segregation of duties, enforced server-side — `07-security-architecture.md` §2). HR_ADMIN sees steps 1–2 contribution views only. +- **States**: CALCULATING failure → step 3 shows the job's problem detail with "Retry calculation"; a run locked (`lockedAt`) renders the whole wizard read-only with the lock reason. + +### 3.7 Audit log explorer (`/audit`) + +- **Purpose**: forensic, filterable view of the append-only `AuditLog` (primary `AUDITOR` surface). +- **Data**: `GET /audit-logs?resourceType&from&to` (+ actor, action, resourceId filters), cursor pagination. +- **Components**: filter bar (all URL-backed: time range with presets, actor picker, action, resourceType, resourceId); virtualized result table (at, actor + role, action, resource link, ip); detail drawer with **before/after JSON diff** viewer (side-by-side, changed keys highlighted, PII fields render redacted per classification — `07-security-architecture.md` §7.3); export to CSV (audited); saved filter sets (local). +- **States**: over-broad query (> 30 days, no filter) prompts narrowing before fetch; empty = "No audit events match"; explorer is strictly read-only for every role — there is no mutating action on this screen by design. + +### 3.8 Company settings (`/settings/*`) + +- **Company Profile**: name, legalName, timezone, currency (currency change requires typed confirmation and is blocked while any non-CLOSED payroll run exists), plan display. +- **Branches & Geofences**: branch CRUD (`/branches`); per-branch geofence editor — interactive map with draggable center pin and radius handle bound to `lat/lng/radiusM` (min radius 50 m, warning under 100 m for GPS accuracy), address search, multiple `Geofence` rows per branch with active toggles; changes affect punch validation immediately — the save dialog states this and links affected shift population count. +- **Shifts**: `Shift` CRUD; start/end with overnight (`isNight`) handling, break/grace minutes, `overtimePolicyJson` edited through a structured form (threshold, multiplier, rounding) — never raw JSON; deactivation blocked while future `ShiftAssignment`s reference the shift (offer bulk-reassign). +- **Leave Policies**: `LeaveType` CRUD (color, paid, attachment-required) and `LeavePolicy` per type (accrualRule NONE/MONTHLY/YEARLY/ANNIVERSARY, accrualDays, maxBalance, maxCarryover, minNoticeDays, maxConsecutiveDays, appliesTo audience builder); simulation panel: "for employee X, next accrual on date Y grants Z days"; policy edits apply prospectively — banner clarifies no retroactive rebalancing without an explicit HR tool. +- **Salary Components**: `SalaryComponent` CRUD (EARNING/DEDUCTION/EMPLOYER_COST; calc FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA with a validated formula editor — known variables, live preview against a sample salary), `taxable`, `statutoryCode`; `SalaryStructure` composer (ordered component list, preview payslip); components used by any non-CLOSED run are edit-locked. +- **Holiday Calendars**: per-year calendars, branch mapping (`branchIds`), optional-holiday flags; import national presets. +- **Roles & Permissions**: role catalog (built-in read-only + custom roles), permission-set editor grouped by resource, `RoleAssignment` management with scopeType COMPANY/BRANCH/DEPARTMENT; every change here is highlighted as audited and takes effect on next token refresh (`07-security-architecture.md` §3.3). + +All settings mutations are confirmed (destructive ones with typed confirmation), audited, and follow the pessimistic write policy (§5.3). + +### 3.9 Announcements (`/announcements`) + +- **Purpose**: publish and manage company/branch communications (`Announcement` entity). +- **Data**: `GET /announcements` (list incl. scheduled/expired with status filter), `POST /announcements` (`announcement:publish`). +- **Components**: list table (title, audience summary, priority, publishAt, expiresAt, createdBy, delivery state); composer drawer — title, rich-text-lite body (bold/lists/links only), audience builder producing `audienceJson` (company-wide / branches / departments / employment types, with live recipient-count preview), `publishAt` scheduler, optional `expiresAt`, priority (NORMAL/HIGH — HIGH triggers push notification, stated in the composer). +- **Primary actions**: publish now, schedule, edit-before-publish (published announcements are immutable — corrections publish a follow-up), expire early. +- **States**: recipient count of zero blocks publish with audience-fix hint; scheduled items show countdown; `BRANCH_MANAGER` composer locks audience to own branches. + +### 3.10 Documents (`/documents`, and per-employee tab in §3.2) + +- **Purpose**: manage `EmployeeDocument` records (contracts, IDs, certificates) with verification workflow. +- **Data**: document list per employee (or company-wide expiring-documents view), upload via API-issued signed URL, verify action setting `verifiedBy`. +- **Components**: expiring-soon dashboard strip (documents with `expiresAt` within 90/30/7 days, filterable by kind/branch); per-employee document table (kind, name, size, mime icon, expiry chip, verified badge with verifier); upload dropzone (type/size validation client-side, virus-scan status from server before the row becomes downloadable); in-browser preview for PDF/images via short-lived signed URLs — never long-lived public links. +- **Primary actions**: upload (`document:write`), verify (`document:verify`, requires viewing the document first — the verify button unlocks after preview open), replace (versioned; prior version retained per retention policy), delete (typed confirmation, audited). +- **States**: quarantined upload (scan pending/failed) shows a non-downloadable row with status; empty per-employee = checklist of expected kinds from the onboarding template (P2). + +## 3.11 Cross-screen interaction standards + +- **Drawers over full navigations** for detail/inspect flows (exception detail, leave detail, audit entry) — the underlying list keeps its scroll and filter state; the drawer's open state and subject id live in the URL so it survives refresh and is shareable. +- **Confirmation tiers**: (1) plain confirm dialog for reversible actions; (2) consequence-summary dialog (shows affected counts) for cascading actions; (3) typed confirmation (entity name or period) for destructive/financial actions — deactivate employee, approve payroll run, change currency, delete document. +- **Date handling controls**: every date-range filter offers presets (today, this week, this month, last month, custom); custom ranges over 92 days on heavy endpoints (attendance days, audit) require explicit "run large query" acknowledgment. +- **Toasts** confirm completed mutations with an undo affordance only where a true inverse operation exists (never for payroll/roster publish); all toasts are announced via the polite live region (§6). + +## 4. Routing and URL state + +- Route tree mirrors §2.1; every screen's *complete* view state — filters, search text (debounced), cursor, sort, selected row id, wizard step, drawer open — is encoded in search params via a typed `useUrlState` hook (schema-validated, defaults elided). Guarantees: deep-linkable, refresh-safe, back/forward-correct, shareable between admins ("look at this exception"). +- Route guards: `RequireRole` / `RequirePermission` wrappers redirect unauthorized entries to Dashboard with a toast; guard config is generated from the same permission catalog constants the sidebar uses. + +## 5. Server-state management + +### 5.1 TanStack Query conventions + +- **Query keys** are structured tuples: `['employees', cid, filters]`, `['leave', 'requests', cid, filters]`, `['payroll', 'runs', cid, runId]`. `cid` in every key makes company switch a cache-namespace switch (plus `queryClient.clear()` on switch for defense in depth). +- **Cursor pagination** via `useInfiniteQuery`; `meta.cursor` from the API envelope is the page param. +- **Freshness tiers**: live board `staleTime: 0` + 60 s `refetchInterval`; queues/lists 30 s; reference data (shifts, leave types, components) 15 min; analytics 5 min. `refetchOnWindowFocus` on for queues, off for wizards. +- **Mutations** invalidate the narrowest sufficient keys; decision mutations also update the detail record from the response body to avoid a refetch flash. +- 401 → single token refresh retry then sign-out; 403 → permission-drift handler (refetch `GET /me`, recompute gating, toast "Your access changed"); 429/5xx → capped exponential backoff, max 3 (never for mutations without an idempotency key — the fetch wrapper attaches `Idempotency-Key` (ULID) to every POST per master spec §5, so mutation retries are safe). + +### 5.2 RBAC-driven UI gating + +- `GET /me` returns profile + roles + permission strings; a `can(permission, scope?)` helper backs a `` component and hook. +- Policy: controls the user can never use are **removed**; controls unavailable due to state (locked run, hard conflicts) are **disabled with a reason tooltip**. Gating is UX only — the server is authoritative (master spec §1.1) — so every mutating call still handles 403 gracefully. + +### 5.3 Optimistic updates policy + +| Class | Policy | Examples | +|---|---|---| +| Local-feel toggles, low blast radius | Optimistic (`onMutate` cache patch, rollback `onError` with toast) | notification read, saved filters, sidebar prefs, announcement draft edits | +| Queue decisions | **Pessimistic-fast**: row enters "deciding…" state, removed only on 2xx; 409 removes with "already decided" info | leave decide, regularization decide, exception approve | +| Money / compliance / structural | **Strictly pessimistic**: blocking confirm, spinner on the action only, no cache mutation until 2xx | payroll anything, salary edits, roster publish, geofence/policy changes, deactivation, role changes | + +Rationale: admins act on other people's records with financial consequence; a rolled-back "approved" that the admin already believed is worse than 400 ms of latency. + +### 5.4 Virtualization for 100k-employee tenants + +- All unbounded tables (directory, live board, payslip review, audit) use TanStack Virtual with fixed row height (48 px default / 40 px dense); windowed rendering keeps DOM < 100 rows regardless of dataset. +- Data windowing: `useInfiniteQuery` pages of 200; scroll position prefetches the next page at 75% depth; total counts come from `meta` when the API can provide them cheaply, else "10,000+" style indeterminate counts. +- Never client-side filter/sort over the full population: filters and sorts are server parameters (URL-backed). Client-side operations are permitted only within an already-scoped page (e.g. roster week grid for one branch). +- Roster grid virtualizes rows (employees) and keeps 7 day-columns static; drag interactions use overlay positioning to stay virtualization-safe. + +## 6. Accessibility (WCAG 2.1 AA) + +- **Keyboard**: every interaction reachable without a pointer — including roster drag-and-drop (cell focus, Enter opens shift picker, arrow-key move mode with live announcements) and map geofence editing (numeric lat/lng/radius inputs always present beside the map). +- **Structure**: landmarks (`nav`, `main`, `header`), one `h1` per screen, skip-to-content link, focus management on route change (heading receives focus), focus trap + restore in drawers/dialogs (Radix). +- **Tables**: real `` semantics preserved under virtualization (`aria-rowcount`/`aria-rowindex`), sortable headers with `aria-sort`, row actions in-tab-order. +- **Live regions**: polite announcements for async completions (calculation finished, N rows approved), poll refreshes silent. +- **Color**: 4.5:1 minimum contrast in both themes; status never conveyed by color alone (chips carry text/icons — e.g. leave type chips pair `colorHex` with the code); charts have accessible table alternatives ("view as data"). +- **Forms**: label every control, `aria-describedby` errors, `problem+json` violations mapped to fields, error summary link-list on submit failure. +- **CI gate**: axe-core on every Playwright flow; new violations fail the build. Manual screen-reader pass (NVDA + VoiceOver) per release on the five highest-traffic screens. + +## 7. Internationalization + +- ICU MessageFormat catalogs (react-intl); default `en`; no hardcoded strings in components (lint-enforced). Pseudo-locale build for expansion/RTL smoke testing; layout is logical-properties-based (`margin-inline-start`) so RTL locales work without overrides. +- Dates/times render in the **company timezone** (from `Company.timezone`) with the viewer's locale formatting; timestamps show timezone hints when viewer locale ≠ company zone. All API exchange is UTC ISO-8601; date-only fields (roster dates, leave dates) are timezone-less calendar dates and are never shifted. +- Currency amounts format with `Company.currency` (`Intl.NumberFormat`); payroll never renders a bare number without its currency. +- Translatable server content (problem details, insight texts) arrives keyed with server-side interpolation values; the client maps keys through the same catalogs. + +## 8. Performance budgets + +| Metric | Budget | Enforcement | +|---|---|---| +| Initial JS (gzipped, entry + vendor) | ≤ 250 KB; route chunks lazy-loaded per sidebar section | CI bundle-size check fails PRs over budget | +| LCP on Dashboard (P75, corporate network) | ≤ 2.5 s | Lighthouse CI on every merge to main | +| Interaction latency: table scroll at 100k rows | 60 fps target, no frame > 50 ms | Playwright trace assertion on directory scroll scenario | +| Route transition (cached data) | ≤ 200 ms to first meaningful paint | TanStack Query cache-first rendering; skeleton only on cold cache | +| API chatter | No polling faster than 60 s; no duplicate in-flight queries (Query dedupe) | Code review + MSW test asserting request counts | + +Charts lazy-load their rendering library; the map (geofence editor) loads only on its settings route. `React.memo`/stable-callback discipline is applied only where profiling shows re-render cost (virtualized rows, roster cells) — not speculatively. + +## 9. Session lifecycle and error handling + +- **Token refresh**: fetch wrapper refreshes the Firebase ID token when < 5 min from expiry; concurrent requests share one refresh promise. +- **Idle timeout**: configurable per tenant (default 30 min) — modal warning at T−2 min, then sign-out with return-URL preservation; hard cap 12 h regardless of activity for admin sessions. +- **Permission drift** (role changed mid-session): any 403 on a previously permitted action refetches `GET /me`, recomputes gating, and shows "Your access has changed"; the sidebar re-renders immediately (doc `07-security-architecture.md` §3.3). +- **Global error boundary** per route section: a crashed screen renders a recovery card (reload section / report) without unmounting the shell; errors ship to the client telemetry endpoint with release hash and `traceId` correlation to server logs — no PII in telemetry payloads. +- **Multi-tab consistency**: BroadcastChannel propagates sign-out and company switch across tabs; mutation invalidations rely on refetch-on-focus rather than cross-tab cache sync. + +## 10. Shared component inventory + +Console screens compose exclusively from the WorkTrack Web DS; screen code contains layout and wiring, not bespoke widgets. + +| Component | Used by | Notes | +|---|---|---| +| `DataTable` | Directory, live board, payslip review, audit, requests | Virtualized (§5.4), URL-bound sort/filter, column chooser, selection model, a11y table semantics (§6) | +| `EntityDrawer` | All detail/inspect flows (§3.11) | URL-bound open state, focus trap, lazy content query | +| `FilterBar` | Every list screen | Schema-driven from the screen's `useUrlState` definition; renders chips, presets, clear-all | +| `StatusChip` | Attendance/leave/payroll/run statuses | Enum-mapped color+icon+label; never color-only (§6) | +| `KpiStat` / `TrendChart` | Dashboard, payroll review | Chart lib lazy-loaded; "view as data" table fallback | +| `AudienceBuilder` | Announcements, leave policy `appliesTo`, calendar branch mapping | Emits the canonical audience JSON; recipient-count preview query | +| `GeoMapEditor` | Branch geofences | Lazy route-level load; paired numeric inputs for a11y (§6) | +| `WizardShell` | Payroll run | Step state from URL + resource status; guards forward navigation on exit criteria (§3.6) | +| `JsonDiffViewer` | Audit detail | Side-by-side, key-level highlight, classification-aware redaction display | +| `ConfirmDialog` (3 tiers) | All mutations (§3.11) | Typed-confirmation variant for tier 3 | +| `PermissionGate` (``) | Everywhere | Removes (not disables) unauthorized controls (§5.2) | +| `ProblemCard` / `EmptyState` / `SkeletonGroup` | Canonical states (§3) | problem+json mapping, correlation id display | + +## 11. Testing strategy + +| Level | Scope | Tooling / gate | +|---|---|---| +| Unit | `useUrlState` schema round-trips, `can()` gating logic, audience JSON builder, formatter utilities (currency/timezone) | Vitest; PR-blocking | +| Component | DS components incl. keyboard interaction contracts (roster cell picker, drawer focus trap), all four canonical states per screen shell | React Testing Library + axe-core assertions; PR-blocking | +| Integration | Screen ↔ API flows against MSW fake `/v1` (pagination, optimistic vs pessimistic mutation classes incl. 409/422 paths, permission-drift 403 handling, token refresh) | Vitest + MSW; request-count assertions (§8); PR-blocking | +| E2E per persona | One journey each: COMPANY_ADMIN settings edit, HR_ADMIN leave approval, PAYROLL_ADMIN full 5-step run, BRANCH_MANAGER roster publish, AUDITOR audit drill-down + export | Playwright against staging seed tenant; axe scan per page visited; release-blocking | +| Visual regression | DS components + dashboard/roster/wizard layouts, light+dark, LTR+RTL pseudo-locale | Playwright screenshots with checked-in goldens; PR-blocking on diff | + +Seed data: a deterministic fixture tenant (3 branches, 250 employees, one closed + one draft payroll run, pending approvals in every queue) is rebuilt per E2E run so tests never depend on mutable shared state. diff --git a/docs/07-security-architecture.md b/docs/07-security-architecture.md new file mode 100644 index 0000000..f5028d3 --- /dev/null +++ b/docs/07-security-architecture.md @@ -0,0 +1,306 @@ +# WorkTrack — Security Architecture + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§2.1, §5, §7) · Companions: `05-android-architecture.md`, `06-web-admin-design.md`, `08-sync-strategy.md` + +**Purpose.** This document is the platform security specification for WorkTrack: the threat model, identity and custom-claims design on Firebase Auth, the deny-by-default authorization chain and full permission catalog, the Firestore security-rules strategy, the attendance anti-fraud stack (device binding, Play Integrity, kiosk TOTP, face verification) with its biometric privacy posture, data protection and compliance controls (PII classification, GDPR, SOC 2 mapping), and the secure development lifecycle. Every control here is normative for backend, Android, and web implementations; exceptions require a documented risk acceptance signed by the security owner. + +--- + +## 1. Security objectives and trust boundaries + +Objectives, in priority order: (1) **tenant isolation** — no data or action ever crosses a `companyId` boundary; (2) **payroll and attendance integrity** — money-bearing records cannot be forged, replayed, or silently altered; (3) **PII/biometric confidentiality**; (4) **accountability** — every privileged mutation is attributable and immutable in audit. + +Trust boundaries: mobile devices and browsers are **untrusted** (they propose, never decide — master spec §3); Cloud Functions API is the sole trusted policy-enforcement point; Firestore is reachable by clients only through security rules that treat the API as the writer of record (§5); kiosk devices are semi-trusted terminals holding a device-scoped `KIOSK` identity and no employee data. + +## 2. Threat model (STRIDE) + +| # | Threat | STRIDE | Vector | Impact | Mitigations (normative) | +|---|---|---|---|---|---| +| T1 | Spoofed GPS punch | Spoofing | Mock-location app, rooted device, GPS simulator fakes an in-fence punch | Wage fraud | Play Integrity verdict on punch (§6.2); `isMock` flag captured per fix (§6.3); server geofence re-validation from raw lat/lng; speed-of-travel plausibility (§6.4); punches flagged not silently dropped → exceptions queue | +| T2 | Face photo/video replay | Spoofing | Photo of an employee shown to camera for face punch | Buddy punching | On-device liveness (ML Kit) before embedding; server-side match threshold tunable (§6.6); face punch bound to bound device + integrity token; anomaly review queue | +| T3 | Token theft | Spoofing / Elevation | Stolen Firebase ID/refresh token from device backup, malware, or network | Account takeover | Short-lived ID tokens (≤ 1 h); refresh token bound to Firebase installation; tokens stored only in Keystore-backed EncryptedSharedPreferences (§7.2); TLS 1.2+ everywhere; punch endpoints additionally require bound `deviceId` + integrity token, so a bare token cannot punch (§6.1); revocation flow (§3.4) | +| T4 | Tenant isolation breach | Info disclosure / Tampering | Crafted `companyId` in URL/body differing from token; IDOR on ULIDs | Cross-company data leak | Tenant resolved **only** from verified claims; URL/body `companyId` must equal `cid` or 403 (master spec §2.1); every Firestore access path is `companies/{cid}/…` derived from claims; no cross-tenant queries exist in the API; ULIDs are non-guessable but never relied on as secrets | +| T5 | Privilege escalation | Elevation | Client-forged role list; role changed via unprotected endpoint; stale claims after demotion | Unauthorized admin actions | Roles live in custom claims set only by backend admin SDK on `RoleAssignment` change (§3.3); deny-by-default RBAC middleware (§4); claims revocation + `auth_time`/`iat` check against `claimsUpdatedAt` for sensitive scopes; role management itself requires `role:assign` and is audited | +| T6 | Kiosk token replay | Spoofing / Replay | Screenshot/relay of kiosk QR used later or from elsewhere | Remote buddy punching | TOTP QR: 30 s window, HMAC-signed over (kioskId, timeStep) with per-kiosk secret (§6.5); server accepts current ±1 step once — **single-use enforcement** via consumed-token cache keyed (kioskId, timeStep, employeeId); kiosk branch must match employee branch; kiosk secret rotation | +| T7 | Insider payroll fraud | Tampering / Repudiation | PAYROLL_ADMIN inflates a salary, edits a closed run, or approves own run | Financial loss | Segregation of duties: `payroll:approve` requires approver ≠ `startedBy` (server-enforced); runs immutable after `lockedAt`; salary changes require `salary:write` + audit with before/after; approve step requires recent re-authentication; variance alerts in review step (doc 06 §3.6); immutable audit log (§7.5) | +| T8 | Punch record tampering | Tampering | Client edits/deletes a synced punch to erase lateness | Attendance fraud | Punches are append-only at every layer: no update/delete API, Room exposes no update DAO, Firestore rules deny all client writes (§5); `AttendanceDay` is a server-computed projection clients cannot write | +| T9 | Sync replay / duplicate mutation | Tampering | Replayed `POST /sync/push` batch or duplicated outbox delivery | Double leave requests, duplicate punches | ULID `Idempotency-Key` per op, honored on all POSTs (master spec §5); idempotency store returns the original result for replays (doc 08 §4.2) | +| T10 | Audit log erasure | Repudiation | Compromised admin deletes audit trail | Untraceable fraud | `auditLogs` append-only: no update/delete in API or rules; BigQuery export as second copy (§7.5); AUDITOR role reads independently of COMPANY_ADMIN | +| T11 | PII exfiltration via logs/exports | Info disclosure | PII in application logs, over-broad exports | Privacy breach, GDPR exposure | Structured log redaction (§7.4); export endpoints permission-gated and audited; PII classification drives field-level handling (§7.3) | +| T12 | Denial of service on API | DoS | Credential-stuffing bursts, sync-push floods | Availability loss | Per-identity and per-IP rate limits at the API layer; sync batch caps + backpressure signals (doc 08 §4.4); Firebase Auth built-in abuse protection; Cloud Functions autoscaling with per-tenant quota guards | + +Residual risks are tracked in the risk register with owners and review dates; T2 liveness bypass by sophisticated 3D masks is accepted-with-monitoring at P1 (compensating control: exceptions queue + device binding). + +## 3. Identity + +### 3.1 Authentication flows + +- **Android**: Firebase Auth (email/password; SSO providers per tenant plan). SDK manages refresh; the app never touches raw refresh tokens. Post-auth, the session is not usable until device binding (`POST /devices`) succeeds (doc 05 §5.1). +- **Web Admin**: Firebase Auth Web SDK; console rejects sessions holding no admin role (doc 06 §2). Payroll approval and role management require **recent authentication** (re-auth if `auth_time` older than 15 min). +- **Kiosk**: provisioned by an admin; a device-scoped account holding only the `KIOSK` role and a kiosk registration; it can render QR tokens and nothing else — no employee reads, no punch submission (employees' apps submit punches). +- Password policy delegated to Firebase with enforced minimums (length ≥ 12, breach-list screening); email verification required before first API access; MFA (TOTP) available and mandatory for `COMPANY_ADMIN`/`PAYROLL_ADMIN` on Enterprise plan tenants. + +### 3.2 Custom claims + +Exactly as master spec §2.1: + +```json +{ "cid": "01J8…COMPANY", "r": ["BRANCH_MANAGER", "EMPLOYEE"], "b": ["01J8…BR1", "01J8…BR2"], "eid": "01J8…EMP" } +``` + +- `cid` — tenant id; single company per credential (multi-company users hold separate credentials; the web company switcher re-authenticates). +- `r` — role codes (master spec §1.1), resolved to permission sets **server-side per request** so permission-set edits to custom roles apply without re-minting tokens. +- `b` — branch scope ids for branch-scoped roles; empty for company-wide roles. +- `eid` — employee id, binding the auth identity to the `Employee` row (`authUid` back-reference verified at claim-mint time). + +Claims are minted exclusively by backend admin-SDK code paths triggered by `RoleAssignment` writes; no client input ever reaches claim values. Total claims payload kept < 1000 bytes (Firebase limit); large branch scopes (> ~30 branches) overflow to a server-side scope document referenced during tenant-context load, and `b` carries a sentinel `"*many"`. + +### 3.3 Claim propagation on role change + +1. Role mutation (`role:assign`) writes `RoleAssignment` and audit log in one transaction. +2. Firestore trigger recomputes the subject's claims, calls `setCustomUserClaims`, and stamps `claimsUpdatedAt` on the employee's auth metadata doc. +3. Old ID tokens (≤ 1 h) may still carry stale claims. Handling: **downgrade-sensitive** areas (payroll, role management, employee PII bulk read, audit export) compare token `iat` against `claimsUpdatedAt` and force refresh (401 `type: token-stale`) when older; ordinary endpoints tolerate the ≤ 1 h window because server-side permission resolution already reflects removed *permissions* for custom roles. +4. Demotion or exit additionally calls `revokeRefreshTokens(uid)`, capping staleness to the current ID token's remaining lifetime; `POST /employees/{id}/deactivate` does this plus device revocation. +5. Clients react to 401 `token-stale` with a silent `getIdToken(true)` and one retry. + +### 3.4 Session and device revocation + +- **Device revocation**: `DELETE /devices/{id}` sets `revokedAt`; punch and sync endpoints reject revoked `deviceId`s regardless of token validity; FCM token is invalidated. Surfaced in web (employee profile → Devices) and Android settings. +- **Session revocation**: `revokeRefreshTokens` on password reset, suspected compromise, exit, and admin "sign out everywhere". Middleware checks `auth_time` against revocation time on sensitive scopes. +- **Offboarding** (`status=EXITED`): disable Firebase user, revoke refresh tokens, revoke all devices, clear FCM tokens; audit entry `employee:deactivate` records the cascade. + +## 4. Authorization + +### 4.1 Middleware chain (deny-by-default) + +Every `/v1` route passes the full chain (master spec §7); a route missing an explicit permission declaration fails closed at startup (route-table lint). + +``` +verifyToken → validate Firebase ID token signature/expiry/audience; extract claims +tenantContext → resolve cid; assert URL companyId (if present) === cid; load company status (suspended tenant → 403); hydrate role→permission sets; resolve overflow branch scope +requirePermission(p) → assert p ∈ resolved permissions, else 403 problem+json `permission-denied` (no existence leaks: scope-mismatched resource reads return 404) +scopeNarrowing → inject mandatory filters from claims (branch scope, self scope) into the handler's query context (§4.3) +handler → business logic; every privileged mutation writes AuditLog in the same transaction +``` + +### 4.2 Permission catalog + +Permissions are `resource:action` strings (master spec §1.1). The catalog below is exhaustive for API v1; roles are bundles of these (built-in bundles listed in §4.4). + +| API area (master spec §5) | Endpoint(s) | Permission | +|---|---|---| +| Session | `GET /me` | *(any authenticated tenant member)* | +| Session | `POST /devices` | `device:bind` | +| Session | `DELETE /devices/{id}` | `device:revoke` (self) / `device:manage` (others) | +| Org | `GET /branches`, `/departments`, `/positions` | `org:read` | +| Org | create/update/delete branches, departments, positions | `org:write` | +| Org | `GET /employees`, `GET /employees/{id}` | `employee:read` (self always permitted for own record) | +| Org | `POST/PUT /employees` | `employee:create` / `employee:write` | +| Org | `POST /employees/{id}/deactivate` | `employee:deactivate` | +| Attendance | `POST /attendance/punches` | `attendance:punch` (self only, ever) | +| Attendance | `GET /attendance/punches`, `GET /attendance/days` | `attendance:read-self` / `attendance:read` (others) | +| Attendance | `POST /attendance/regularizations` | `attendance:regularize` (self) | +| Attendance | `POST /attendance/regularizations/{id}/decide` | `attendance:approve` | +| Shifts | `GET /shifts` | `shift:read` | +| Shifts | shift CRUD | `shift:write` | +| Shifts | `GET /rosters` | `roster:read` | +| Shifts | `PUT /rosters` | `roster:write` | +| Shifts | `POST /shift-swaps` | `shift-swap:request` (self) | +| Shifts | `POST /shift-swaps/{id}/decide` | `shift-swap:decide` | +| Leave | `GET /leave/types` | `leave:read-types` (all members) | +| Leave | `GET /leave/balances` | `leave:read-balance-self` / `leave:read-balance` (others) | +| Leave | `POST /leave/requests`, `POST /leave/requests/{id}/cancel` | `leave:request` (self) | +| Leave | `GET /leave/requests` | `leave:read-self` / `leave:read` (others) | +| Leave | `POST /leave/requests/{id}/decide` | `leave:approve` | +| Payroll | `GET /payroll/runs` | `payroll:read` | +| Payroll | `POST /payroll/runs` | `payroll:run` | +| Payroll | `POST /payroll/runs/{id}/approve` | `payroll:approve` (approver ≠ starter, enforced in handler) | +| Payroll | `GET /payslips?employeeId&year`, `GET /payslips/{id}` | `payroll:read-self` (own) / `payroll:read` (others) | +| Payroll | salary structures/components/employee salaries | `salary:read` / `salary:write` | +| Comms | `GET /announcements`, `GET /notifications`, `POST /notifications/{id}/read` | *(any member; audience-filtered)* | +| Comms | `POST /announcements` | `announcement:publish` | +| Analytics | `GET /analytics/kpis`, `GET /analytics/insights` | `analytics:read` (scope-narrowed) | +| Audit | `GET /audit-logs` | `audit:read` | +| Sync | `POST /sync/push`, `GET /sync/pull` | *(any member; every batched op re-checked against the op's own permission — sync grants nothing by itself, doc 08 §4)* | +| Documents | employee document read/upload/verify | `document:read-self` / `document:read` / `document:write` / `document:verify` | +| Roles | role & assignment management | `role:read` / `role:assign` | + +### 4.3 Scope narrowing + +Holding a permission is necessary, not sufficient; the effective scope is intersected with claims: + +- **Branch scope**: for sessions whose granting role has `scopeType=BRANCH`, `tenantContext` injects `branchId ∈ b` as a mandatory filter on every list/read and validates it on every mutation target (e.g. a `BRANCH_MANAGER` with `roster:write` can `PUT /rosters` only for `branchId ∈ b`; `leave:approve` only where the requester's `branchId ∈ b`). +- **Self scope**: `*-self` permissions resolve the target to `eid`; a request naming another employeeId under a self-only permission is 403. +- **Department scope** (`scopeType=DEPARTMENT`) narrows analogously for TEAM_LEAD. +- Narrowing is implemented as query-context injection, not handler discipline: handlers physically cannot issue an unscoped Firestore query because the tenant-context repository prefixes `companies/{cid}` and appends scope filters centrally. + +### 4.4 Built-in role bundles (summary) + +`EMPLOYEE`: all `*-self` + `attendance:punch`, `leave:request`, `shift-swap:request`, `device:bind/revoke(self)`, `org:read`, `shift:read`, `leave:read-types`. `TEAM_LEAD`: EMPLOYEE + dept-scoped `attendance:read`, `leave:read`, `leave:approve`, `attendance:approve`, `analytics:read`. `BRANCH_MANAGER`: TEAM_LEAD at branch scope + `roster:read/write`, `shift-swap:decide`, `employee:read`, `announcement:publish` (branch audience). `HR_ADMIN`: company-scoped org/employee/attendance/leave/document/announcement full set + `payroll:read` (no `payroll:approve`, no `salary:write` unless granted). `PAYROLL_ADMIN`: `payroll:*`, `salary:*`, `employee:read`, `attendance:read`, `leave:read`, `audit:read` (payroll resources). `COMPANY_ADMIN`: everything except cross-tenant. `AUDITOR`: every `*:read` + `audit:read`, zero write permissions. `KIOSK`: none (kiosk token flow only). `SUPER_ADMIN`: internal ops plane, out of tenant catalog. + +## 5. Firestore security rules strategy + +Principle (master spec §7): **the API is the only writer**; rules are defense-in-depth, not the primary policy engine. + +- **No client writes, anywhere**: `allow write: if false` on every collection under `companies/{cid}`. All mutations flow through Cloud Functions using the Admin SDK (which bypasses rules); therefore any rule-permitted client write path would be a bug — there are none. This covers T8 (punch tampering) and keeps balances/attendanceDays/payslips server-authoritative. +- **Reads, deny-by-default with narrow self-service allowances** for SDK-based reads that exist today (FCM-driven badge counts; future listeners): a client may read only documents belonging to its own employee — `notifications` where `resource.data.employeeId == token.eid`, `announcements` where the audience matches, own `employees/{eid}` profile doc. Every allowance also asserts `request.auth.token.cid == cid` (path tenant match). All other collections — `punches`, `attendanceDays`, `leaveBalances`, `leaveRequests`, `payrollRuns`, `payslips`, `employeeSalaries`, `auditLogs`, `devices`, `roleAssignments`, everything in §4.6 of the master spec — are `read: if false` to clients; the app reads them through `/v1` + sync, never through the SDK. +- **Rules mirror claims, never documents**: rules reference only `request.auth.token` (cid/eid) — no `get()` lookups, keeping rules O(1), non-bypassable via doc tampering, and cheap. +- **Storage rules** (Cloud Storage): payslip PDFs and documents are served via short-lived signed URLs minted by the API after a permission check; face-template objects have no client-readable path at all. +- Rules are code-reviewed like API code, covered by the Firestore rules emulator test suite (allow/deny matrix per collection × persona), and deployed atomically with functions. + +Normative shape (excerpt — the checked-in `firestore.rules` is generated from this pattern): + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // Global default: nothing is readable or writable. + match /{document=**} { allow read, write: if false; } + + match /companies/{cid} { + function sameTenant() { return request.auth != null && request.auth.token.cid == cid; } + function isSelf(eid) { return sameTenant() && request.auth.token.eid == eid; } + + // Narrow self-service read allowances only; zero client writes anywhere. + match /employees/{eid} { allow read: if isSelf(eid); } + match /notifications/{nid} { allow read: if sameTenant() + && resource.data.employeeId == request.auth.token.eid; } + match /announcements/{aid} { allow read: if sameTenant(); } // audience refined server-side + // punches, attendanceDays, leaveBalances, leaveRequests, payrollRuns, payslips, + // employeeSalaries, auditLogs, devices, roleAssignments, …: no match block ⇒ denied. + } + } +} +``` + +## 6. Attendance anti-fraud stack + +Layered: each control is independently bypassable in theory; the stack plus review queues makes systematic fraud uneconomical. Signals **flag** (`serverValidated=false`, `invalidReason`) rather than drop — no silent data loss, and honest edge cases (poor GPS) stay recoverable via regularization. + +### 6.1 Device binding + +- One active `Device` per employee per platform (policy-tunable). `POST /devices` records platform, model, appVersion, FCM token, first integrity verdict; server issues the `deviceId` the client must present on every punch and sync push. +- Punch endpoints reject: unknown `deviceId`, revoked device, or `deviceId` bound to a different `eid` (mismatch is a high-severity audit event → T3, T6). +- Re-binding a new device auto-revokes the old one after a cool-down and notifies the employee (out-of-band fraud signal). + +### 6.2 Play Integrity + +- Standard-request tokens with server-issued nonces (doc 05 §7.2). Server decodes verdicts and applies tenant-tunable policy: `MEETS_DEVICE_INTEGRITY` required by default for punch acceptance; `MEETS_BASIC_INTEGRITY`-only → accept-but-flag; `MEETS_NO_INTEGRITY` / unlicensed → reject punch persistence as valid, record with `invalidReason=INTEGRITY_FAILED`. +- Verdict cached per device ≤ 15 min to bound API quota; latest verdict stored on `Device.integrityVerdict`. +- Unavailability (no Play services, API outage) degrades to accept-and-flag with `INTEGRITY_UNAVAILABLE` — availability failure must not lock out honest workforces (T1 residual accepted, exceptions queue compensates). + +### 6.3 Mock-location detection + +`isMock` per GPS fix travels with the punch payload. Server treats client flags as advisory (a compromised client lies): `isMock=true` → `invalidReason=MOCK_LOCATION`; absence of the flag proves nothing, hence §6.4. + +### 6.4 Speed-of-travel plausibility + +For each accepted GPS punch, server computes great-circle distance / elapsed time against the employee's previous located punch. Implied speed > threshold (default 900 km/h hard-fail; 150 km/h soft-flag, tunable) → `invalidReason=IMPLAUSIBLE_TRAVEL`. Accuracy radii are added to distance tolerance to avoid false positives; hard-fails still persist (append-only) but never auto-validate. + +### 6.5 Kiosk TOTP QR + +Per master spec §5: kiosk displays a rotating QR encoding `{kioskId, timeStep, sig}` where `sig = HMAC-SHA256(kioskSecret, kioskId ‖ timeStep)`; 30 s step. Employee app scans and submits `POST /attendance/punches {method: QR, kioskToken}`. Server verification, in order: kiosk exists/active → HMAC valid → timeStep ∈ {now−1, now, now+1} → **single-use**: `(kioskId, timeStep, employeeId)` unseen (consumed-token cache, TTL 120 s) → kiosk branch == employee branch → device binding + integrity as for GPS. Kiosk secrets: 256-bit, per kiosk, stored in Secret Manager, rotated 90 d or on suspicion; kiosk clock drift monitored via its token-refresh calls (drift > 1 step alerts ops). Screenshot relay within the 30 s window from a colleague *at the same branch* remains the residual (T6); single-use-per-employee plus device binding bounds it to self-punching in person-adjacent time, and face method (P1) closes it where required. + +### 6.6 Face verification (P1) + +- Enrollment: consented capture → on-device quality/liveness gate → embedding computed → embedding uploaded over TLS to Cloud Storage (CMEK-optional path per master spec §7); **raw capture deleted immediately after embedding extraction, on device and never stored server-side**. +- Verification punch: on-device liveness (ML Kit) → embedding → server compares against enrolled template; match threshold is **server-tunable per tenant** (`faceScore` recorded on the punch); below-threshold → `invalidReason=FACE_MISMATCH`, flagged not dropped. +- Thresholds calibrated against false-accept ≤ 0.1% at operating point; drift review quarterly. + +### 6.7 Biometric privacy + +- **Embeddings only** — no raw face images at rest anywhere (master spec §7). Embeddings are classified Restricted-Biometric (§7.3), encrypted at rest, access limited to the verification service path; not exportable via any API. +- **Consent**: explicit, per-employee, recorded (who/when/policy-version) before enrollment; refusal must leave a working alternative punch method (GPS/QR) — tenants enable face as optional or must document a lawful basis. +- **Deletion**: embedding deleted on consent withdrawal, employee exit (with retention respecting local law), and tenant offboarding; deletion is audited and propagates to backups per §7.6 crypto-shredding. +- **Regional law**: biometric features are tenant-configurable per jurisdiction. GDPR: biometric data = special category (Art. 9) — explicit consent + DPIA required, DPIA template shipped to tenants. US: Illinois BIPA-style statutes require written release, retention schedule, and prohibition on sale — the platform's written-consent flow and deletion schedule are designed to satisfy BIPA as the strictest baseline. Tenants operating where consent cannot be freely given in employment contexts (several EU DPAs' position) are steered to non-biometric methods; the platform never makes face the sole punch method. + +## 7. Data protection + +### 7.1 Encryption + +- **Transit**: TLS 1.2+ (TLS 1.3 preferred) for all client↔API, API↔Firestore/Storage paths; HSTS on hosting; certificate pinning is deliberately **not** used on Android (operational risk > benefit given Play Integrity + token binding), documented as a risk decision. +- **At rest**: Google-managed encryption for Firestore/Storage/BigQuery by default; CMEK option for face-template bucket and document vault on Enterprise plan (master spec §7). + +### 7.2 Client-side secret storage (Android) + +- Firebase session persisted by the SDK; every WorkTrack-managed secret — cached ID token metadata, `deviceId`, kiosk provisioning secret (kiosk build), FCM token — lives in **EncryptedSharedPreferences backed by an Android Keystore AES-256 master key** (`MasterKey`, StrongBox where available). Nothing security-bearing in plain SharedPreferences, files, or Room. +- Room holds business data only; no tokens. Database-level encryption (SQLCipher) is not applied by default (device FDE + no-secrets-in-Room); tenants may require it via managed-config flag. +- `android:allowBackup="false"` for security-bearing stores (backup rules exclude EncryptedSharedPreferences files); screenshots blocked (`FLAG_SECURE`) on payslip and face-enrollment screens. + +### 7.3 PII classification + +| Class | Fields (canonical model, master spec §4) | Handling | +|---|---|---| +| Restricted-Biometric | face embeddings, `faceScore` context | §6.7: CMEK-optional, no API export, consent-gated, crypto-shred on deletion | +| Restricted-Financial | `EmployeeSalary.*`, `Payslip*`, `PayrollRun.totalsJson`, bank details (P2) | `salary:*`/`payroll:*` permissions only; masked in UI until reveal-click (audited); never in logs, analytics events, or push payload bodies | +| Confidential-PII | name, email, phone, `avatarUrl`, address, documents, `lat/lng` on punches, leave reasons/attachments | Encrypted at rest; log-redacted (§7.4); export audited; push notifications carry IDs + generic titles, never field values | +| Internal | org structure, shifts, rosters, policies, announcements | Tenant-scoped standard handling | +| Public | none — no WorkTrack data is public | — | + +### 7.4 Log redaction + +- Structured JSON logs only; a central serializer applies a field-level **allowlist** — unknown fields are dropped, classified fields (email, phone, names, lat/lng, salary amounts, token strings) are redacted to type-tagged placeholders or salted hashes (correlatable, not reversible). +- Request logs record route template + IDs, never bodies for classified routes (`/payroll/*`, `/employees/*`, punch payloads). Correlation id (`traceId`) links logs ↔ audit ↔ problem responses. +- Log retention 30 d (app logs) / 400 d (security events); log access itself is IAM-restricted and audited (SOC 2 CC7). + +### 7.5 Audit log immutability + +- `AuditLog` is append-only (master spec §4.5): API exposes only `GET /audit-logs`; no update/delete handler exists; Firestore rules deny client writes wholesale (§5); the writer path is a dedicated service module invoked in-transaction with privileged mutations. +- Continuous export to BigQuery (append-only dataset, table-level immutability via IAM — the functions service account holds insert-only) provides the tamper-evident second copy; daily row-count/hash reconciliation between Firestore and BigQuery alerts on divergence (covers T10). +- Entries carry `beforeJson/afterJson` with classified fields redacted per §7.3 at write time — the audit trail itself must not become a PII amplifier. + +### 7.6 GDPR + +- **Roles**: tenant = controller, WorkTrack = processor; DPA + subprocessor list published; regional data residency per Firebase multi-region selection at tenant provisioning. +- **DSRs (Data Subject Requests)**: master spec §7 — API-backed workflows for access/export (machine-readable JSON of all rows keyed by `employeeId`), rectification (profile fields), erasure, and restriction. Erasure of an exited employee: identity fields overwritten with tombstone values; financial/attendance records required for statutory retention are **pseudonymized** (employeeId retained, direct identifiers severed) until their retention clock expires, then deleted. +- **Retention**: per-class schedule (payroll records per local statute, default 7 y; punches/attendance 3 y; audit 7 y; notifications 90 d; face embeddings: employment duration only). Cloud Scheduler retention jobs enforce; deletions audited. +- **Crypto-shredding**: exports, backups, and the document vault are encrypted under per-tenant (Enterprise: per-employee for biometrics) data keys; destroying the key renders residual copies unreadable, satisfying erasure across backups without backup rewrites. +- Breach handling: processor notification to controllers without undue delay (target ≤ 48 h) with scope, records affected, remediation. + +### 7.7 SOC 2 control mapping + +| Control (TSC) | WorkTrack implementation | +|---|---| +| CC6.1 Logical access | Firebase Auth + custom claims; deny-by-default RBAC (§4); MFA for admin roles | +| CC6.2/6.3 Provisioning & least privilege | RoleAssignment workflow with `role:assign` gate; scope narrowing; quarterly access review report generated from RoleAssignments + audit | +| CC6.6 Boundary protection | TLS everywhere; Firestore rules deny-by-default (§5); no public data plane | +| CC6.7 Data in transmission/removal | §7.1; signed-URL, expiring media access; crypto-shredding (§7.6) | +| CC6.8 Unauthorized software | Play Integrity on punch path (§6.2); dependency scanning (§8) | +| CC7.1/7.2 Monitoring & anomaly detection | Security event log; integrity/mock/speed flags into exceptions queue; sync-health telemetry (doc 08 §8) | +| CC7.3/7.4 Incident response | On-call runbooks, severity matrix, breach comms (§7.6); post-incident review with control updates | +| CC8.1 Change management | PR review gates, CI checks, staged rollout, rules+functions atomic deploy (§8) | +| A1.2 Availability | Multi-region Firebase, autoscaling functions, backpressure (T12); RTO/RPO stated in ops runbook | +| C1.1/C1.2 Confidentiality | PII classification (§7.3) + retention/disposal schedule (§7.6) | +| PI1 Processing integrity | Idempotency keys, server-authoritative computation, payroll segregation of duties, append-only punches, reconciliation jobs | + +## 8. Secure SDLC + +- **Dependency scanning**: Renovate for automated update PRs; `osv-scanner` (Gradle + npm) in CI, build-blocking on high/critical CVEs with an exception register; Android lint security checks; npm lockfile linting (`lockfile-lint`) against registry tampering. +- **Secrets management**: no secrets in the repo — CI secret scanning (gitleaks) blocks pushes; server secrets (kiosk HMAC keys, service credentials) in GCP Secret Manager with least-privilege service accounts and 90-day rotation; Android signing keys in Play App Signing; `.env`-style local config git-ignored with checked-in redacted examples. +- **Code review gates**: every change via PR; two-reviewer rule for security-sensitive paths (`functions/src/middleware/**`, `firestore.rules`, auth/claims code, payroll engine, crypto/storage utilities — enforced via CODEOWNERS); Firestore rules changes require the emulator allow/deny matrix suite to pass; SAST (Semgrep with the OWASP + custom tenant-isolation rulepack: flags any Firestore query not built through the tenant-scoped repository) on every PR. +- **Testing**: security unit tests are release-blocking — middleware chain (401/403 matrices per role × endpoint from the §4.2 catalog), rules emulator suite, idempotency replay, kiosk token replay/expiry, speed-of-travel cases. +- **Pen-test cadence**: external penetration test annually and before each major phase launch (P1 kiosk/face, P2 payroll, P3 web admin); scope includes tenant-isolation (T4) and payroll segregation (T7) scenarios; findings tracked to closure with 30/60/90-day SLAs by severity. Internal red-team exercise on the punch anti-fraud stack semi-annually. +- **Release hygiene**: staged rollout (internal → 10% → 100%) with crash + security-event monitoring; server deploys are versioned and one-step revertible; `/v1` deprecations follow the master spec's explicit deprecation windows. + +## 9. Security monitoring and incident response + +### 9.1 Security event taxonomy + +High-signal events emitted to the security log (distinct stream from app logs, 400-day retention, §7.4): + +| Event | Source | Default response | +|---|---|---| +| `auth.token_stale_forced_refresh`, `auth.revoked_token_use` | Middleware | Repeated revoked-token use from one IP → block + alert | +| `authz.permission_denied` (with route, permission, role set) | RBAC middleware | > 20/min per identity → alert (probing) | +| `tenant.claim_url_mismatch` | tenantContext | Always alert — should be near zero in legitimate traffic (T4 canary) | +| `device.binding_mismatch`, `device.revoked_use` | Punch/sync handlers | Alert + auto-flag subsequent punches from that identity | +| `fraud.integrity_failed`, `fraud.mock_location`, `fraud.implausible_travel`, `fraud.kiosk_replay`, `fraud.face_mismatch` | Anti-fraud stack (§6) | Feed exceptions queue; tenant-level rate anomaly → security review | +| `payroll.sod_violation_attempt` (approve own run), `payroll.locked_run_mutation` | Payroll handlers | Always alert; audited regardless of outcome | +| `audit.divergence` (Firestore↔BigQuery reconciliation) | Daily job | Page on-call (possible T10) | +| `rules.denied_client_write` | Firestore rules metrics | Any nonzero rate investigated — indicates a client bug or probing | + +### 9.2 Incident response + +- Severity matrix: SEV1 = confirmed cross-tenant access, payroll integrity compromise, or biometric data exposure; SEV2 = single-account takeover, audit divergence; SEV3 = contained fraud attempt, scanner findings in production. SEV1/2 page the on-call immediately; SEV1 additionally invokes the breach-notification clock (§7.6). +- Containment tooling (pre-built, tested quarterly): per-tenant API freeze switch, global punch-endpoint flag-only mode, bulk refresh-token revocation for a tenant, kiosk secret emergency rotation, signed-URL TTL kill-down. +- Every SEV1/2 concludes with a blameless post-incident review within 5 business days; action items land in the risk register (§2) with owners; controls in this document are updated in the same PR as the fix where applicable. diff --git a/docs/08-sync-strategy.md b/docs/08-sync-strategy.md new file mode 100644 index 0000000..252d6a1 --- /dev/null +++ b/docs/08-sync-strategy.md @@ -0,0 +1,307 @@ +# WorkTrack — Offline-First Synchronization Strategy + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§3, §4.5, §5, §6.3) · Companions: `05-android-architecture.md`, `07-security-architecture.md` + +**Purpose.** This document specifies the Android synchronization subsystem end to end: the outbox pattern that makes Room the local source of truth for mutations, the ULID-keyed idempotent push protocol, the cursor-based delta pull, per-resource conflict resolution, WorkManager scheduling under Doze, and the failure-handling and observability contract that guarantees rejected work is surfaced to the user — never silently lost. It is the binding contract for `core:sync` and the server's `/sync` endpoints; both sides must evolve together. + +--- + +## 1. Goals and constraints + +| # | Goal / constraint | Consequence in design | +|---|---|---| +| G1 | **Multi-day offline** operation (field workforces: sites without coverage for shifts or whole rotations) | All reads from Room; outbox durable across process death and reboots; no TTL on queued mutations; cursors resume, never restart | +| G2 | **100k-employee tenants** must not melt the client or the API | Client syncs only its own slice (self + role scope); pull is paginated + batched; push batches capped; server backpressure honored (§4.4) | +| G3 | **Server-authoritative money paths** (attendance validity, balances, payroll — master spec §3) | Client never resolves conflicts on these; push responses reconcile local rows; some resource types are pull-only (§6) | +| G4 | No duplicate side effects despite retries and replays | ULID `idempotencyKey` per op; server idempotency store returns the original result on replay (§4.2) | +| G5 | Causal ordering where it matters (punch IN before OUT; leave apply before cancel) | FIFO **per resource** drain order (§3.4) | +| G6 | No silent data loss (master spec §6.3.6) | Terminal failures become user-visible notifications with actions (§8); quarantine, never delete (§7) | +| G7 | Battery and data budget compatible with a device that punches twice a day | Periodic sync ≥ 15 min interval, batched, delta-only; expedited work reserved for user-initiated actions (§5) | +| G8 | Tenant isolation and RBAC hold on the sync path | `/sync/*` runs the full middleware chain; each pushed op is re-authorized individually (`07-security-architecture.md` §4.2) | + +Non-goals: peer-to-peer sync, multi-device merge for one employee's drafts (last writer wins via server), and web offline (the admin SPA is online-only, doc 06 §1). + +## 2. Component overview + +``` +UI ──event──▶ ViewModel ──▶ UseCase ─┬─▶ Repository (core:data) + │ │ Room txn: upsert row (syncStatus=PENDING) + │ │ + insert OutboxEntry + │ └─▶ SyncRequester.requestExpedited() +Room (source of truth) ◀── reconcile ──┐ + │ +core:sync SyncWorker ── drain outbox ─┴─▶ POST /sync/push + (WorkManager) then delta ────▶ GET /sync/pull?types&cursor +``` + +`core:sync` owns: `SyncWorker` (single entry point), `OutboxProcessor` (push), `DeltaPuller` (pull), `SyncScheduler` (WorkManager wiring), `SyncHealthTracker` (telemetry). Repositories in `core:data` own enqueueing; features never touch the outbox directly (doc 05 §2). + +### 2.1 Room schema (sync tables) + +```sql +CREATE TABLE outbox_entry ( + id TEXT PRIMARY KEY, -- ULID + op_type TEXT NOT NULL, -- CREATE|UPDATE|DECIDE|CANCEL|READ_RECEIPT + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + attempts INTEGER NOT NULL DEFAULT 0, + crash_count INTEGER NOT NULL DEFAULT 0, -- poison detection (§7) + last_error TEXT, + before_json TEXT, -- rollback snapshot for UPDATE ops (§7) + state TEXT NOT NULL DEFAULT 'PENDING', -- PENDING|IN_FLIGHT|DONE|FAILED + queued_at INTEGER NOT NULL +); +CREATE INDEX idx_outbox_drain ON outbox_entry(state, resource_id, queued_at, id); -- FIFO-per-resource pick +CREATE INDEX idx_outbox_resource ON outbox_entry(resource_type, resource_id); + +CREATE TABLE sync_cursor ( + resource_type TEXT PRIMARY KEY, + cursor TEXT NOT NULL, -- opaque server token (§4.3) + last_synced_at INTEGER NOT NULL +); +``` + +Punch round trip (happy path): + +```mermaid +sequenceDiagram + participant UI as Punch Screen + participant VM as ViewModel/UseCase + participant R as Room + participant W as SyncWorker + participant API as POST /sync/push + + UI->>VM: onEvent(ConfirmPunch) + VM->>R: txn: insert AttendancePunch(syncStatus=PENDING) + OutboxEntry(PENDING) + R-->>UI: Flow emits — chip "Recorded, will verify" + VM->>W: SyncRequester.requestExpedited() + W->>R: pick oldest PENDING per resource → IN_FLIGHT + W->>API: batch {idempotencyKey, op, payload+integrityToken} + API-->>W: results[APPLIED {serverValidated:true, updatedAt}] + W->>R: txn: entry→DONE; punch row ← server fields, syncStatus=SYNCED + R-->>UI: Flow emits — chip "Verified" + W->>API: GET /sync/pull?types=…&cursor + API-->>W: changes + next cursor + W->>R: txn: apply page + advance SyncCursor +``` + +## 3. Outbox pattern (push side) + +### 3.1 Enqueue contract + +Every offline-capable mutation is one **atomic Room transaction**: + +1. Upsert the domain row optimistically with `syncStatus = PENDING` (for creates, the client generates the entity's ULID id — offline-generatable and sortable, master spec §4). +2. Insert an `OutboxEntry` (master spec §4.5): `id` (ULID), `opType` (`CREATE|UPDATE|DECIDE|CANCEL|READ_RECEIPT`…), `resourceType`, `resourceId`, `payloadJson` (the API request body), `idempotencyKey` (fresh ULID, minted once at enqueue and never regenerated), `attempts = 0`, `state = PENDING`, `queuedAt`. + +Because both writes commit together, a crash can never produce a visible optimistic row without its outbox entry or vice versa (doc 05 §5.5). Punches additionally have **no** UPDATE/DELETE opTypes at all — append-only end to end (master spec §6.3.5). + +### 3.2 Lifecycle state machine + +```mermaid +stateDiagram-v2 + [*] --> PENDING : enqueued (atomic with optimistic Room write) + PENDING --> IN_FLIGHT : picked by OutboxProcessor (oldest first per resource) + IN_FLIGHT --> DONE : 2xx ack — reconcile row, syncStatus=SYNCED + IN_FLIGHT --> PENDING : transient failure (network, 408/429/5xx)\nattempts++, lastError set, backoff + IN_FLIGHT --> FAILED : permanent rejection (400/403/404/409/422)\nor attempts ≥ maxAttempts + FAILED --> PENDING : user retry (only for retryable classes) + FAILED --> [*] : resolved/discarded via explicit user action (audited locally) + DONE --> [*] : pruned after 7 days (kept for diagnostics) +``` + +Retry policy: + +| Failure class | Examples | Transition | Policy | +|---|---|---|---| +| Transient | offline, timeout, 408, 429, 500–504 | → PENDING | Retry with WorkManager exponential backoff (§5); `attempts` unbounded for connectivity, bounded at `maxAttempts = 10` for server 5xx | +| Permanent — business rejection | 422 (stale balance, policy violation), 409 (already decided), 403 | → FAILED (terminal) | Never auto-retried; reconcile per conflict matrix (§6) + notify (§8) | +| Permanent — malformed | 400 schema errors | → FAILED (quarantine, §7) | Client bug; telemetry alert | +| Crash recovery | app killed while IN_FLIGHT | IN_FLIGHT → PENDING at worker start | Safe because replay with the same `idempotencyKey` is a no-op server-side | + +### 3.3 Idempotency keys + +- One ULID `idempotencyKey` per logical operation, minted at enqueue, immutable across all retries of that entry — this is what makes at-least-once delivery safe (G4). +- Sent per-op inside the push batch (and as the `Idempotency-Key` header for direct non-sync POSTs, master spec §5). +- Server keeps an idempotency store keyed `(cid, idempotencyKey)` with the canonical response, retained ≥ 30 days ≥ any realistic offline window; replays return the stored outcome without re-executing side effects. + +### 3.4 Ordering — FIFO per resource + +- Drain order: entries grouped by `resourceId`, groups processed oldest-first (`queuedAt`, tie-break `id` — ULIDs are time-sortable), **strictly sequential within a group**: entry N+1 for a resource is not sent until N reaches DONE or FAILED. +- A FAILED head entry **blocks its own resource's queue** (dependent ops would be nonsense — e.g. cancel of a leave request whose create was rejected); the blocked entries fail fast with `lastError = "blocked by "` and reconcile together (§8). +- Across different resources there is no ordering guarantee, which permits batching (§4.1) and prevents one poisoned resource from stalling the world. Punch IN/OUT pairs share `resourceType=punch` but are distinct append-only resources; their causal order is preserved because the batch preserves enqueue order within a push and the server orders by `punchedAt` (client timestamp) anyway — `AttendanceDay` computation is order-insensitive by design. + +## 4. Wire protocol + +Derived from master spec §5 (`POST /sync/push`, `GET /sync/pull?types&cursor`; envelope `{ data, meta }`; RFC 7807 errors; bearer auth). + +### 4.1 `POST /sync/push` + +Request — up to **50 ops** per batch, enqueue order preserved: + +```json +{ + "deviceId": "01J8…DEV", + "ops": [ + { + "idempotencyKey": "01J9AB…", + "opType": "CREATE", + "resourceType": "punch", + "resourceId": "01J9AA…", + "payload": { "type": "IN", "method": "GPS", "punchedAt": "2026-07-17T08:58:12Z", + "lat": 52.52, "lng": 13.40, "accuracyM": 12, "insideFence": true, + "geofenceId": "01J8…GF", "isMock": false, "integrityToken": "…" } + } + ] +} +``` + +Response — **per-op results** (the batch itself is not transactional): + +```json +{ + "data": { "results": [ + { "idempotencyKey": "01J9AB…", "status": "APPLIED", "resource": { "id": "01J9AA…", "serverValidated": true, "updatedAt": "…" } }, + { "idempotencyKey": "01J9AC…", "status": "REJECTED", + "problem": { "type": "https://api.worktrack.app/problems/stale-leave-balance", + "title": "Insufficient leave balance", "detail": "Requested 3.0 days, available 1.5" } }, + { "idempotencyKey": "01J9AD…", "status": "DUPLICATE", "resource": { "…": "…" } } + ] }, + "meta": { "throttle": null } +} +``` + +- `APPLIED` → entry DONE; response `resource` fields overwrite the local row (**server fields win**, master spec §6.3.4), `syncStatus = SYNCED`. +- `DUPLICATE` (idempotency replay) → treated exactly as APPLIED. +- `REJECTED` → entry FAILED with the problem stored in `lastError`; reconciliation per §6. +- Each op is individually re-authorized against the §4.2 permission catalog and tenant scope (`07-security-architecture.md`); a whole-batch 401/403 occurs only for token-level failures. + +### 4.2 Server-side apply semantics + +Per op: idempotency-store hit → return stored result; else validate (schema → RBAC/scope → business rules) → apply in a Firestore transaction stamping server `updatedAt` → write audit where applicable → store result. Server `updatedAt` is authoritative and monotonic per resource — it is the pull cursor's basis. + +### 4.3 `GET /sync/pull` + +- Request: `GET /sync/pull?types=punch,attendanceDay,leaveRequest,leaveBalance&cursor=&limit=500`. +- The cursor is **opaque to the client** but canonically encodes, per resource type, the pair `(updatedAt, id)` of the last delivered document; server orders by `(updatedAt ASC, id ASC)` — the ULID `id` tie-breaker makes pagination stable when many rows share an `updatedAt` (bulk server jobs like accruals or `AttendanceDay` recomputation produce exactly this). +- Response: + +```json +{ + "data": { + "changes": [ + { "resourceType": "leaveBalance", "op": "UPSERT", "resource": { "id": "…", "usedDays": 4.5, "version": 7, "updatedAt": "…" } }, + { "resourceType": "leaveRequest", "op": "DELETE", "id": "01J9…", "deletedAt": "…" } + ], + "hasMore": true + }, + "meta": { "cursor": "eyJwdW5jaCI6…" } +} +``` + +- Deletes travel as soft-delete tombstones (`deletedAt`, master spec §4); client hard-deletes local rows after applying, tombstones retained server-side ≥ 90 days so a device offline longer re-bootstraps (§4.5). +- Apply is a single Room transaction per page: upserts overwrite local rows **except** rows with `syncStatus = PENDING` (a not-yet-pushed local change is never clobbered by a pull; the subsequent push resolves it per §6). `SyncCursor(resourceType, cursor, lastSyncedAt)` is updated in the same transaction — a crash between apply and cursor save re-applies an idempotent page, never skips one. +- Scope: the server narrows pulled data exactly as reads are narrowed (self slice for EMPLOYEE; branch slice for scoped managers) — a 100k-employee tenant sends an employee only their own few hundred rows (G2). + +### 4.4 Batching and backpressure + +- Push: ≤ 50 ops/batch, loop until outbox drained or budget exhausted; Pull: `limit ≤ 500`, loop while `hasMore` within the same budget (worker time budget 9 min, well under WorkManager's 10-min cap). +- Server backpressure: 429 with `Retry-After`, or in-band `meta.throttle = { retryAfterSeconds }` on partial service; client defers remaining work to the next scheduled run honoring the hint. Per-device push rate is additionally capped server-side (T12, `07-security-architecture.md` §2). +- Payload hygiene: gzip request/response; pulls exclude heavy blobs (payslip PDFs, attachments are URL references fetched on demand). + +### 4.5 Bootstrap vs incremental + +| Mode | Trigger | Behavior | +|---|---|---| +| **Bootstrap** | First login on a device; cursor reset (tombstone horizon exceeded, schema epoch bump, tenant migration) | Ordered full pull of reference data first (company, branches, shifts, leaveTypes, leavePolicies, holidayCalendars, geofences), then self slice (employee, balances, recent `attendanceDay` 90 d, punches 30 d, leaveRequests 12 mo, payslips 24 mo), then role-scoped extras (approvals). Runs as expedited work with a blocking first-run screen only until reference data + today's slice land; the rest streams in background | +| **Incremental** | Every subsequent sync | Push outbox, then pull deltas per cursor; typical payload < a few KB | + +The server signals cursor invalidity with 410 `type: cursor-expired` → client clears that resource type's cursor and re-bootstraps **that type only**. + +## 5. Scheduling (WorkManager) + +| Work | Type | Constraints | Policy | +|---|---|---|---| +| `sync-periodic` | Unique `PeriodicWorkRequest`, 15 min (WorkManager minimum), `ExistingPeriodicWorkPolicy.UPDATE` | `NetworkType.CONNECTED` | Baseline drain + pull; batteryNotLow **not** set (punches must flow on low battery) | +| `sync-now` | Unique `OneTimeWorkRequest`, `setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)` fallback, `ExistingWorkPolicy.APPEND_OR_REPLACE` | `CONNECTED` | Enqueued by `SyncRequester` on: any outbox enqueue, app foreground, connectivity regained (`NetworkCallback`), pull-to-refresh, FCM sync-nudge data message | +| Punch flush | Same `sync-now` expedited path; punch enqueue always requests expedited quota | `CONNECTED` | Punches are the latency-critical mutation; expedited work gives foreground-service-like priority without a persistent notification. If expedited quota is exhausted, falls back to ordinary one-time work — acceptable because the punch is already durably queued and optimistically visible (doc 05 §6) | +| Backoff | — | — | `BackoffPolicy.EXPONENTIAL`, initial 30 s, doubling, capped at 1 h (WorkManager `MAX_BACKOFF_MILLIS`); jitter inherent in WorkManager scheduling | + +Both work items funnel into the same `SyncWorker` (unique-work mutual exclusion prevents concurrent drains; a run-lock row in Room is a second guard). The worker is idempotent and resumable at any interruption point (§3.2 crash recovery, §4.3 transactional cursor). + +**Doze/battery**: no exemptions requested — the app never asks for `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (Play policy + battery ethics). Doze defers periodic sync to maintenance windows; that is acceptable because (a) punches ride expedited work triggered by user interaction (device is awake by definition), (b) FCM high-priority data messages nudge sync for time-sensitive server events (approval decided), and (c) everything else tolerates deferral. Telemetry tracks `queuedAt → DONE` latency percentiles to verify this holds in the field (§8). + +## 6. Conflict resolution matrix + +Policy per resource type; "client wins" never applies to server-authoritative fields anywhere (G3). + +| Resource type | Class | Client writes? | Conflict handling | +|---|---|---|---| +| `punch` (AttendancePunch) | **Append-only** | CREATE only | No conflicts possible by construction; duplicates collapsed by idempotency key; validity disputes are data (`serverValidated`, `invalidReason`), not conflicts | +| `attendanceDay` | **Server-authoritative projection** | Never | Pull-only; local row always overwritten (versioned via `version` field, stale pulls with lower `version` discarded) | +| `leaveBalance` | **Server-authoritative** | Never | Pull-only; `pendingDays` overlay for optimistic UI is display-time arithmetic, never persisted into the balance row (doc 05 §6) | +| `payslip` / `payslipLine` / `payrollRun` | **Server-authoritative** | Never | Pull-only, immutable once published | +| `leaveRequest` (create/cancel) | **Reject-and-notify** | CREATE, CANCEL | Server validates against current balance/policy at apply time; stale-balance or policy violation → `REJECTED` op result → local row flipped to `REJECTED` with server reason + notification (§8). Cancel racing an approval: 409 `already-decided` → local row takes the server's decided state, user notified | +| `regularizationRequest`, `shiftSwapRequest` | **Reject-and-notify** | CREATE, CANCEL | Same as leaveRequest | +| Approval decisions (`leave/…/decide`, `regularizations/…/decide`, `shift-swaps/…/decide`) | **First-writer-wins (server)** | DECIDE op | Second decision gets 409 → FAILED (terminal, not retried); local state re-pulled; deciding user informed "already decided by X" | +| `employee` profile self-service fields (phone, avatarUrl, emergency contact) | **Last-write-wins per field** | UPDATE (allowed fields only) | Server applies field-level LWW on `updatedAt`; pushed update returns merged row which overwrites local. Org-controlled fields (branch, position, salary linkage) are never client-writable — present in payload → 403 | +| `notificationMessage.readAt` | LWW (monotonic) | READ_RECEIPT | `readAt` only ever set, never cleared; max(readAt) wins trivially | +| Reference data (branches, shifts, geofences, leaveTypes, policies, holidays, announcements) | **Server-authoritative** | Never (admin console mutates via direct API) | Pull-only | +| `device` | Server-managed | Bind/revoke via direct API (online-only) | Not in the outbox at all | + +Guard rails: a pull never overwrites a `syncStatus=PENDING` row (§4.3); after that row's push resolves (APPLIED or REJECTED), the next pull converges it to server truth. Room migrations preserve the outbox and cursors across app updates; a destructive-migration fallback is forbidden in release builds. + +## 7. Failure handling + +- **Poison messages**: an entry that repeatedly crashes the processor (serialization bug, impossible state) is detected by a per-entry crash counter (incremented pre-processing, cleared post); at 3 crashes the entry moves to FAILED with `lastError = POISON` and processing continues with the next resource group — one bad entry cannot wedge sync (G6, §3.4 blocking is per-resource only). +- **Max-attempt quarantine**: FAILED entries are quarantined, not deleted: retained with full payload + `lastError` for 30 days, visible in a debug-accessible "sync issues" screen (user-facing summary per §8, engineer-facing detail via support bundle). Quarantined entries are excluded from drains but included in telemetry. +- **Reconciliation of the optimistic row**: whenever an entry reaches FAILED, the repository reverses or re-labels the optimistic write in the same transaction that records the failure: creates → row marked `syncStatus=REJECTED` with reason (kept, visibly, for the user to act on — e.g. re-apply leave with valid dates; punches are never deleted, they carry `invalidReason`); updates → row restored from `beforeJson` snapshot held on the entry; decides → target re-pulled. +- **Cursor integrity**: pull apply + cursor advance are transactional (§4.3); a corrupted cursor (deserialization failure) resets that type to bootstrap rather than failing sync. +- **Auth failures**: 401 → single forced token refresh + retry; second 401 aborts the run and, if the token is revoked (`07-security-architecture.md` §3.4), triggers the sign-out flow; outbox is preserved for the same user's next session and wiped on a different user's login. +- **Clock skew**: client timestamps (`punchedAt`, `queuedAt`) are recorded with the device's elapsed-realtime anchor plus an NTP-checked offset when available; server records receive time and flags punches with skew > 5 min for the exceptions queue rather than rejecting. + +## 8. Observability and UX surfacing + +### 8.1 Telemetry (SyncHealthTracker) + +Structured, PII-free events (ids + enums only, `07-security-architecture.md` §7.4): per run — duration, ops pushed/applied/rejected, pages pulled, rows applied, backoff state; per entry — `queuedAt → DONE/FAILED` latency; gauges — outbox depth, oldest-pending age, quarantine count, cursor age per resource type. Exported via the analytics pipeline with tenant-level dashboards and alerts: p95 punch sync latency > 10 min, quarantine rate > 0.1%, any poison event, cursor age > 48 h on active devices. + +An in-app diagnostics surface (Profile → Settings → Sync status) shows: last successful sync, pending count, failed count with reasons — the first thing support asks for. + +### 8.2 UX surfacing rules (no silent data loss) + +| Situation | Surface | +|---|---| +| Op pending (offline or queued) | Per-row glyph ⟳ + global offline banner (doc 05 §6); no toast noise | +| Punch flagged by server (`serverValidated=false`) | Row badge + notification "Punch recorded but flagged: . HR can review." linking to attendance history detail | +| Leave/regularization/swap rejected | Local push notification (deep link `worktrack://leave/requests/{id}`) + row state REJECTED with server reason + inline "Apply again" action | +| Decision conflict (409) | Notification "Already decided by "; approvals inbox row resolves to final state | +| Entry quarantined (poison/malformed) | Non-technical notification "Some changes couldn't be saved — tap to review" → sync issues screen listing affected items with retry/discard; discard requires explicit confirmation and is the **only** path that abandons user data | +| Sync degraded (backpressure, repeated 5xx) | Passive banner "Sync delayed — will keep retrying"; no user action solicited | + +Invariant: every terminal FAILED entry produces exactly one user-visible artifact (notification and/or persistent row state). This is asserted in the `core:sync` end-to-end test suite (doc 05 §8: rejection scenarios must observe a notification emission), making G6/master-spec §6.3.6 a tested property, not an aspiration. + +## 9. Verification matrix + +Executable acceptance criteria for `core:sync` (tooling per doc 05 §8: JVM tests, in-memory Room, MockWebServer fake server; WorkManager via `WorkManagerTestInitHelper`): + +| # | Property | Scenario asserted | +|---|---|---| +| V1 | Atomic enqueue | Kill (throw) between row upsert and outbox insert → transaction rolls back; neither is visible | +| V2 | Idempotent replay | Same batch delivered twice (network retry after response loss) → server fake returns DUPLICATE; exactly one local row, one DONE entry | +| V3 | FIFO per resource | Leave create then cancel enqueued offline → cancel never sent before create acked; create REJECTED → cancel fails fast as blocked | +| V4 | Crash mid-flight | Process death with entry IN_FLIGHT → next run resets to PENDING, resends same idempotencyKey, converges to one server record | +| V5 | Pull never clobbers pending | Local PENDING profile edit + pull carrying older server row → local row untouched; after push, next pull converges | +| V6 | Cursor transactionality | Crash between page apply and cursor save → page re-applied idempotently; no gap, no duplicate rows | +| V7 | Cursor expiry | 410 cursor-expired on one type → that type re-bootstraps; other cursors untouched | +| V8 | Rejection surfaces | 422 stale-balance on leave create → row REJECTED with reason, notification emitted exactly once (G6 invariant) | +| V9 | 409 decision race | Decide op returns 409 → entry FAILED terminal (no retry), target re-pulled to decided state, info surfaced | +| V10 | Backoff and backpressure | 500,500,200 sequence → exponential gaps honored; 429 with Retry-After defers remaining batches | +| V11 | Poison isolation | Entry that throws in serialization 3× → quarantined FAILED(POISON); other resources continue draining same run | +| V12 | Offline burst | 200 queued punches over 3 simulated days → drained in ≤ 4 batches, order preserved per resource, all SYNCED | +| V13 | Auth revocation | 401 twice → run aborts, outbox preserved; different-user login wipes outbox and cursors | +| V14 | Migration safety | Room schema bump with pending outbox entries → entries and cursors survive migration (MigrationTestHelper) | + +Server-side mirrors (functions test suite): idempotency-store replay returns byte-identical results; per-op RBAC re-check rejects an op whose permission was revoked after enqueue; tombstone horizon and 410 emission; `updatedAt` monotonicity under concurrent transactions. A release of either side must pass both suites against the shared contract fixtures (JSON golden files for §4 payloads, versioned with `/v1`). diff --git a/docs/09-roadmap.md b/docs/09-roadmap.md new file mode 100644 index 0000000..2e6e6a9 --- /dev/null +++ b/docs/09-roadmap.md @@ -0,0 +1,259 @@ +# WorkTrack — Development Roadmap + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + Product · Derives from: `00-master-spec.md` §8 + +**Purpose.** This document expands the master specification's delivery phases (P0–P4) into an executable milestone plan: per-phase workstreams (Android, Backend, Web, Data/AI, Security/Compliance), concrete deliverables, exit criteria, dependency ordering, a suggested team shape, and the program risk register. It also fixes the P0 definition of done to exactly what `00-master-spec.md` §8 declares implemented in this repository. Phase numbering here is delivery-phase numbering (P0–P4) and must not be confused with requirement priorities (P0/P1/P2) in `01-product-requirements.md`. + +--- + +## 1. Phase overview and dependency ordering + +```mermaid +flowchart TD + P0["P0 — Foundation (this repo, implemented)
Android foundation + backend API core +
Firestore rules + design docs"] + P1["P1 — Scheduling & Trust
rosters UI, regularization, approvals inbox,
face verification, kiosk app mode"] + P2["P2 — Payroll
calculation engine + runs UI,
statutory packs, document vault"] + P3["P3 — Admin & Analytics
Web Admin SPA, analytics dashboards,
BigQuery pipeline"] + P4["P4 — Intelligence & Openness
AI insights, attrition/absence prediction,
anomaly detection, open APIs + webhooks"] + + P0 --> P1 --> P2 --> P3 --> P4 +``` + +Hard dependencies that fix this ordering: + +| Dependency | Reason | +|---|---| +| P1 before P2 | Payroll consumes AttendanceDay projections that are only trustworthy once regularization and roster-driven shift assignment exist (worked/late/OT minutes must be correctable and shift-aware). | +| P2 before P3 payroll dashboards | Analytics over payroll requires PayrollRun/Payslip data to exist. | +| BigQuery pipeline (P3) before AI (P4) | Model training and `/analytics/insights` features read the warehouse, not Firestore. | +| Approvals inbox (P1) before payroll approval UX (P2) | Reuses the same role-gated approvals surface on Android. | +| Kiosk mode (P1) independent of payroll | Can ship in parallel inside P1; depends only on P0 punch validation + `KIOSK` role. | +| Web Admin (P3) after API hardening (P0–P2) | The SPA consumes the same `/v1` API; shipping it against a churning payroll API would force rework. Design (`06-web-admin-design.md`) proceeds earlier; implementation is P3. | + +Soft parallelism: Security/Compliance and Data/AI workstreams run continuously; each phase below lists their concurrent obligations. + +### 1.1 Workstream map across phases + +| Workstream | P0 (done) | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---| +| Android | Foundation: modules, features, offline sync | Rosters, regularization, approvals inbox, face, kiosk mode | Payroll runs UI, document vault | Directory/announcement polish | Insight surfaces | +| Backend | API core: middleware, punch, leave, sync, payslip read | Rosters/swaps/regularization/kiosk/face/accruals/holidays | Payroll engine, statutory packs, exports | Analytics API, DSR, residency | Open API, webhooks, SSO/SCIM | +| Web | — | Design finalization only | Scaffolding (late) | **Web Admin SPA + dashboards** | Insights + platform consoles | +| Data/AI | — | Event taxonomy → staging BQ | Payroll events, reconciliation | **BigQuery pipeline prod**, KPI layer | Models, anomaly detection, serving | +| Security/Compliance | Rules, middleware, token model | Integrity blocking, DPIA, kiosk secrets | SoD, retention, statutory change control | SOC 2 Type I, pen test, DSR runbook | AI governance, SOC 2 Type II | + +--- + +## 2. P0 — Foundation (this repo, implemented) + +### 2.1 P0 definition of done + +P0 is done exactly when the following — the master spec §8 P0 scope, verbatim in substance — is implemented and verifiable in this repository: + +1. **Android build foundation**: `build-logic/` convention plugins — `worktrack.android.application`, `worktrack.android.library`, `worktrack.android.library.compose`, `worktrack.android.feature`, `worktrack.android.hilt`, `worktrack.android.room`. +2. **Core modules**: `core:common`, `core:model`, `core:database`, `core:network`, `core:datastore`, `core:domain`, `core:data`, `core:sync`, `core:designsystem` — wired per the module graph in master spec §6.1 (features depend on domain/designsystem/common; data composes database/network/datastore; sync owns workers, outbox processor, scheduling). +3. **Feature modules**: `feature:auth` (Login → ForgotPassword → DeviceBinding), `feature:dashboard`, `feature:attendance`, `feature:leave`, `feature:payslips`, `feature:profile` — navigable per master spec §6.2 (AuthGraph → MainGraph, bottom bar Dashboard/Attendance/Leave/Profile, deep links `worktrack://leave/requests/{id}`, `worktrack://payslips/{id}`, `worktrack://approvals`). +4. **Offline & sync contract**: Room as local source of truth (Flow DAOs), optimistic writes with `syncStatus=PENDING`, OutboxEntry with ULID `idempotencyKey`, `SyncWorker` (network-constrained, exponential backoff, unique work) draining FIFO-per-resource then delta-pulling per SyncCursor; punches append-only; server-authoritative conflict policy with actionable rejection notifications. +5. **Backend API core** (Cloud Functions, Node 20, TypeScript, Express, `/v1`): auth/tenant/RBAC middleware chain (verify token → tenant context from claims `{cid,r,b,eid}` → permission check → handler, deny-by-default); attendance punch endpoint with validation (geofence, device binding, `serverValidated`/`invalidReason`); leave requests + decisions (approval chain, balance movements); sync push/pull (batched idempotent ops, delta cursors); payslip read endpoints. +6. **Firestore security rules**: no direct client access to server-authoritative collections; rules as second line of defense behind the API. +7. **Full design docs**: `00`–`09` document set present and mutually consistent, with `00-master-spec.md` canonical. + +Exit is binary: each of the seven items above either exists in-repo and passes its checks or P0 is not done. No partial credit; no other feature counts toward P0. + +### 2.2 P0 verification checklist + +| Check | Method | +|---|---| +| Module graph matches spec §6.1 | Gradle project structure + dependency assertions in convention plugins | +| Offline punch → sync exactly-once | Instrumented test: airplane mode punch, reconnect, assert single server record | +| Middleware chain deny-by-default | API tests: missing token 401, wrong tenant 403, missing permission 403 | +| Idempotent sync push | Replay same batch, assert no duplicate effects | +| Firestore rules deny client writes | Rules-emulator test suite over server-authoritative collections | +| Docs consistency | Cross-reference review: entities/roles/paths in 01/02/09 vs 00 | + +--- + +## 3. P1 — Scheduling & Trust + +Theme: make attendance data correct and correctable at branch scale; extend capture surfaces. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Roster views (my schedule, team roster for `TEAM_LEAD`/`BRANCH_MANAGER`); regularization request flow; **approvals inbox** (role-gated: leave, regularization, shift swaps); face-verification punch capture; **kiosk app mode** (rotating TOTP QR display for `KIOSK` devices) | Approvals inbox destination already navigable in P0 shell (`worktrack://approvals`) | +| Backend | `GET/PUT /rosters` + ShiftAssignment write paths; rotation generation jobs (Cloud Scheduler → batched Cloud Tasks); roster locks; `POST /attendance/regularizations` + `/decide` with AttendanceDay recompute; `POST /shift-swaps` + `/decide`; kiosk token issuance/verification (HMAC, 30 s window, branch cross-check); face-embedding pipeline (Cloud Storage, raw-capture deletion, server-tunable threshold); leave accrual scheduler; holiday calendars | +| Web | `06-web-admin-design.md` finalized against real P1 APIs (design only; no SPA build) | +| Data/AI | Pub/Sub event taxonomy frozen (`punch.recorded`, `leave.decided`, `roster.changed`); events flowing to a staging BigQuery dataset | De-risks P3 pipeline | +| Security/Compliance | Play Integrity enforcement on punch endpoints moves from log-only to blocking; speed-of-travel plausibility checks live; kiosk secret provisioning/rotation runbook; face-data DPIA completed | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P1.M1 | Rosters end-to-end (`GET/PUT /rosters`, roster views, rotation generation jobs, locks) | P0 done | 100k-slice generation load test; lock-override audit test | +| P1.M2 | Regularization loop (request → chain decide → AttendanceDay recompute) + approvals inbox | P1.M1 (shift-aware days) | Recompute ≤ 60 s after approval; chain-permission tests | +| P1.M3 | Kiosk mode (TOTP QR issuance/display/verification, secret provisioning) | P0 punch validation | Replay/expiry/branch-mismatch rejection tests; offline-kiosk drill | +| P1.M4 | Face verification (embedding pipeline, threshold, capture UX) | DPIA approved | FAR/FRR measured on eval set incl. demographic slices | +| P1.M5 | Leave hardening (accrual scheduler, holiday calendars, optional-holiday elections) | P0 leave core | Accrual idempotency re-run test; holiday-aware day math tests | + +**Exit criteria.** A 500-employee, 3-branch pilot tenant runs 4 consecutive weeks where: rosters generate ahead ≥ 28 days with zero manual DB fixes; ≥ 95% of invalid/missed punches are resolved via regularization in-app; kiosk check-in round-trip (scan → server-validated) p95 ≤ 5 s; face match false-accept rate ≤ 0.1% at configured threshold on the eval set; approvals inbox drives all three request types end-to-end; zero P0-regression on the sync contract (regression suite green). + +## 4. P2 — Payroll + +Theme: money. Highest-correctness phase; ships behind per-tenant enablement. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Payroll runs UI for `PAYROLL_ADMIN`/`COMPANY_ADMIN` (run lifecycle DRAFT→CALCULATING→REVIEW→APPROVED→PAID→CLOSED, exception queue review); payslip detail upgrades (PayslipLine breakdown, PDF); document vault (EmployeeDocument upload/view, expiry reminders) | Runs UI on Android per spec §8; full desktop ergonomics arrive with P3 Web Admin | +| Backend | Calculation engine: SalaryComponent evaluation (FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA), SalaryStructure/EmployeeSalary effective-dating; run orchestration via per-tenant Cloud Tasks queues (250-employee batches, quarantine on per-employee failure); AttendanceDay/LeaveRequest period integration (workedDays, paidLeaveDays, lopDays, overtimeMinutes); arrears routing for post-lock regularizations; **statutory packs** v1 (versioned, `statutoryCode` binding, launch jurisdictions); payslip PDF rendering to Cloud Storage; approval + segregation-of-duties; payment register / GL exports | +| Web | — (design refinements only) | +| Data/AI | Payroll events into staging BigQuery; reconciliation notebook (run totals vs warehouse) used as release gate | +| Security/Compliance | SoD enforcement tests; payroll audit-trail review (every state transition audit-logged with `totalsJson` snapshot); 7-year retention plumbing for payroll-affecting AuditLog; statutory pack change-control process | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P2.M1 | Salary configuration (components, structures, EmployeeSalary effective-dating) | P0 done | Overlap-rejection and formula-validation tests | +| P2.M2 | Calculation engine + run orchestration (Cloud Tasks batches, quarantine, progress) | P2.M1, P1.M2 (trustworthy AttendanceDay) | 100k synthetic run ≤ 30 min with forced retries/restarts | +| P2.M3 | Statutory packs v1 (versioned, launch jurisdictions) | P2.M2 | External reviewer sign-off per jurisdiction | +| P2.M4 | Payslips + PDFs + runs UI (lifecycle, exception queue, SoD approve) | P2.M2 | Immutability of CLOSED runs under test; SoD self-approve blocked | +| P2.M5 | Arrears + exports (post-lock corrections → next run; payment register, GL) | P2.M4 | Arrears traceability test; export totals = `totalsJson` | +| P2.M6 | Document vault (upload, expiry reminders, signed URLs) | independent within P2 | Access audit-logged; T-30/T-7 reminder tests | + +**Exit criteria.** Parallel-run gate: for 2 pilot tenants, 2 consecutive months of WorkTrack payroll match the incumbent system to the cent for ≥ 99.5% of payslips, with every mismatch explained and dispositioned. 100k-employee synthetic tenant completes a run ≤ 30 min with zero lost/duplicated payslips across forced task retries and function restarts. CLOSED runs immutable under test. Statutory outputs validated by an external reviewer per launch jurisdiction. + +## 5. P3 — Admin & Analytics + +Theme: desk personas and decision support. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Directory + announcements polish; analytics deep-link handoffs | Light phase for Android | +| Backend | `/analytics/kpis` served from BigQuery; DSR endpoints (export/erasure with pseudonymization); data-residency provisioning (region pinned at tenant creation); org directory search index | +| Web | **Web Admin SPA** (React 18 + TS, Firebase Hosting) implementing `06-web-admin-design.md`: org management, employee lifecycle, policy configuration (leave/shift/holiday), rosters, approvals, payroll console, audit-log explorer, **analytics dashboards**; WCAG 2.1 AA gate (axe-core CI) | Consumes the identical `/v1` API — no privileged endpoints | +| Data/AI | **BigQuery pipeline** production-grade: Firestore export + streaming events, tenant-partitioned datasets, freshness SLO ≤ 24 h (streamed ≤ 5 min); KPI semantic layer; per-tenant cost-attribution tables | +| Security/Compliance | SOC 2 Type I audit readiness (controls per `07-security-architecture.md`); GDPR DSR runbook live; pen test of Web Admin + API | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P3.M1 | BigQuery pipeline production (export + streaming, partitioned datasets, freshness SLO) | P1 event taxonomy | Freshness monitors ≤ 24 h / ≤ 5 min streamed; reconciliation vs Firestore | +| P3.M2 | Web Admin core (auth, org, employees, policies, rosters, approvals) | API stable through P2 | Task-parity list for `COMPANY_ADMIN`/`HR_ADMIN` | +| P3.M3 | Web Admin payroll console + audit-log explorer | P3.M2, P2 complete | `PAYROLL_ADMIN`/`AUDITOR` task parity; SoD honored in UI | +| P3.M4 | Analytics dashboards + `/analytics/kpis` on BigQuery | P3.M1 | p95 ≤ 3 s per panel at 100k; zero Firestore scans | +| P3.M5 | Compliance surface (DSR endpoints, data residency provisioning) | independent within P3 | DSR export ≤ 72 h automated; region-pinning verified incl. backups | + +**Exit criteria.** Web Admin reaches task-parity for `COMPANY_ADMIN`/`HR_ADMIN`/`PAYROLL_ADMIN`/`AUDITOR` daily jobs (defined task list, 100% completable without Android or support intervention); dashboards serve a 100k-employee tenant with p95 ≤ 3 s per KPI panel and zero Firestore collection scans; DSR export ≤ 72 h automated; axe-core zero critical violations; SOC 2 Type I report issued or scheduled with zero open high findings. + +## 6. P4 — Intelligence & Openness + +Theme: differentiation on top of a trusted data asset. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Insight surfaces (manager nudges: absenteeism risk, OT anomaly) with explanation + confidence UI | Advisory-only presentation | +| Backend | **Open API** program: published OpenAPI spec, scoped API keys (reusing `resource:action` permissions), partner rate tiers; **webhooks** (HMAC-signed, ≥ 3 retries + DLQ, secret rotation); SSO (OIDC/SAML) + SCIM provisioning | +| Web | Insights dashboards; webhook/API-key management console; insight feedback capture (accept/dismiss) for model improvement | +| Data/AI | **AI insights**: absenteeism-risk and attrition-signal models, overtime/punch **anomaly detection**; feature pipelines in BigQuery; per-tenant opt-out; model cards + monitoring (drift, calibration); `GET /analytics/insights` serving layer | +| Security/Compliance | AI governance: human-review requirement (no automated adverse action), bias evaluation across branches/departments, DPIA for profiling; webhook/API-key abuse monitoring; SOC 2 Type II period underway | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P4.M1 | Open API program (OpenAPI spec, scoped API keys, partner rate tiers) | P0–P3 API stability | External team builds an integration from docs alone | +| P4.M2 | Webhooks (HMAC signing, retries + DLQ, secret rotation, console) | P4.M1 | Delivery ≥ 99.5% within 5 min; replay-from-DLQ drill | +| P4.M3 | SSO (OIDC/SAML) + SCIM | independent within P4 | Certification against two major IdPs; deprovision ≤ 5 min | +| P4.M4 | AI feature pipelines + models (absenteeism, attrition, OT/punch anomaly) | P3.M1 warehouse | Held-out AUC ≥ 0.75 vs baseline; bias evaluation passed | +| P4.M5 | Insight serving + surfaces (`GET /analytics/insights`, manager UI, feedback loop) | P4.M4 | 100% explanation coverage; opt-out honored; no automated adverse action | + +**Exit criteria.** Insights beat naive baselines on held-out data (e.g. absenteeism risk AUC ≥ 0.75 vs seasonal baseline) and are live for opt-in tenants with explanation coverage of 100% of surfaced insights; webhook delivery success ≥ 99.5% within 5 min (excluding endpoint-down); at least 2 external integrations built on the open API by a non-WorkTrack team using published docs alone; SSO/SCIM certified against two major IdPs. + +--- + +## 7. Cross-phase milestone dependencies + +```mermaid +graph TD + P0D["P0 done
(§2.1 definition of done)"] + P0D --> P1M1["P1.M1 Rosters"] + P0D --> P1M3["P1.M3 Kiosk"] + P0D --> P1M5["P1.M5 Leave hardening"] + P1M1 --> P1M2["P1.M2 Regularization + approvals inbox"] + DPIA["DPIA approved"] --> P1M4["P1.M4 Face verification"] + P0D --> P2M1["P2.M1 Salary config"] + P1M2 --> P2M2["P2.M2 Calc engine + orchestration"] + P2M1 --> P2M2 + P2M2 --> P2M3["P2.M3 Statutory packs"] + P2M2 --> P2M4["P2.M4 Payslips + runs UI"] + P2M4 --> P2M5["P2.M5 Arrears + exports"] + EVT["P1 event taxonomy"] --> P3M1["P3.M1 BigQuery pipeline"] + P2M5 --> P3M3["P3.M3 Web payroll console"] + P3M2["P3.M2 Web Admin core"] --> P3M3 + P3M1 --> P3M4["P3.M4 Dashboards"] + P3M1 --> P4M4["P4.M4 AI models"] + P4M1["P4.M1 Open API"] --> P4M2["P4.M2 Webhooks"] + P4M4 --> P4M5["P4.M5 Insight serving"] +``` + +The critical path is P0 → P1.M1 → P1.M2 → P2.M2 → P2.M4 → P2.M5 → P3.M3: everything payroll-trustworthy depends on shift-aware, correctable attendance. Kiosk (P1.M3), face (P1.M4), document vault (P2.M6), the BigQuery pipeline (P3.M1), and SSO/SCIM (P4.M3) are off-critical-path and absorb schedule slack. + +## 8. Tenant rollout playbook (per phase) + +| Stage | Scope | Gate to next stage | +|---|---|---| +| Internal dogfood | WorkTrack's own tenant on staging-parity prod config | Feature-complete, exit-criteria suites green | +| Design partners | 2–3 tenants, feature-flagged, weekly feedback loop | 4 weeks stable; pilot metrics met (phase exit criteria) | +| Early access | Opt-in tenants, self-serve enablement | Support load ≤ 5 tickets/1k employees/month; SLOs held | +| General availability | Flag default-on for new tenants; migration comms for existing | Phase gate review recorded (§12) | + +Payroll (P2) adds a mandatory parallel-run stage between design partners and early access for every tenant, regardless of size: one full cycle matched against the incumbent before WorkTrack becomes the paying system. + +## 9. Team shape suggestion + +| Role | P0–P1 | P2 | P3 | P4 | Notes | +|---|---|---|---|---|---| +| Android engineers | 3 | 2 | 1 | 2 | Peak early: foundation, sync, kiosk, approvals | +| Backend (TS) engineers | 3 | 4 | 3 | 3 | Peak in P2: payroll engine + statutory packs | +| Web engineers | 0 | 1 (prep) | 3 | 2 | SPA is P3; one engineer starts scaffolding late P2 | +| Data engineer | 0.5 | 1 | 2 | 2 | Event taxonomy from P1; pipeline in P3 | +| ML engineer | 0 | 0 | 0.5 | 2 | Joins late P3 for feature pipelines | +| QA / SDET | 1 | 2 | 2 | 2 | Payroll parallel-run automation is a dedicated effort | +| Security engineer | 0.5 | 1 | 1 | 1 | Shared → dedicated from P2 (SoD, SOC 2, pen test) | +| Product manager | 1 | 1 | 1.5 | 1.5 | Second PM (part-time) for Web Admin + platform/API | +| Engineering manager / TL | 1 | 1 | 1.5 | 1.5 | | +| **Total (approx.)** | **10** | **13** | **15.5** | **17** | | + +Structure: one durable **platform pod** (API core, sync, infra, security) and per-phase **feature pods** (scheduling, payroll, web/analytics, AI). Statutory pack authoring pairs backend engineers with contracted per-jurisdiction payroll domain experts — do not staff this as pure engineering. + +## 10. Risk register (top 10) + +| # | Risk | Likelihood | Impact | Mitigation | Owner | +|---|---|---|---|---|---| +| R1 | Payroll miscalculation damages trust irreparably | Medium | Critical | P2 parallel-run gate (2 months, ≥ 99.5% match-to-the-cent); per-employee quarantine instead of silent failure; immutable CLOSED runs; statutory pack versioning + external review | Backend lead | +| R2 | Firestore hot-spots / cost blowout at 100k-employee tenants | Medium | High | Sharded counters, projection reads, BigQuery offload, per-tenant cost attribution with alerts (arch doc §6); 100k synthetic-tenant load test as a standing release gate from P1 | Platform pod | +| R3 | Punch spoofing (mock GPS, rooted devices, replayed kiosk QR) undermines the core product claim | High | High | Device binding + Play Integrity blocking from P1; TOTP window + HMAC + branch cross-check; speed-of-travel checks; monitored spoof-attempt metrics; bug-bounty scope | Security eng | +| R4 | Sync-contract bugs cause silent data loss in the field | Medium | Critical | Append-only punches; idempotency ledger; per-item push results; "no silent loss" is a tested invariant (chaos suite: kill app/network mid-sync); rejection → actionable notification | Android lead | +| R5 | Statutory packs wrong or stale per jurisdiction | High | High | Versioned packs with change control; jurisdiction launch checklist incl. external validation; runs record pack version; disclaimed generic mode outside supported jurisdictions | PM + Backend | +| R6 | Web Admin (P3) slips, blocking enterprise deals | Medium | High | Design (`06-web-admin-design.md`) finalized in P1 against real APIs; API hardened by P2 so SPA work is UI-only; scaffolding starts late P2; task-parity exit list fixed up front | Web lead | +| R7 | Face verification: bias, false accepts/rejects, privacy backlash | Medium | High | Embeddings-only storage + raw-capture deletion + CMEK option; server-tunable threshold; per-tenant opt-in; DPIA in P1; measured FAR/FRR across demographic slices before enable | Security eng + PM | +| R8 | Cloud Functions cold starts break punch latency SLO at scale | Medium | Medium | min-instances on punch/sync functions; latency SLO monitoring from P0; preserved Cloud Run migration path (ADR-006) with rehearsed cutover | Platform pod | +| R9 | Compliance gaps (GDPR DSR, residency, SOC 2) discovered late by enterprise procurement | Medium | High | Security/Compliance workstream runs every phase; DSR + residency land in P3 before enterprise GA; SOC 2 Type I in P3, Type II period in P4; control mapping maintained in `07-security-architecture.md` | Security eng | +| R10 | AI insights (P4) produce unfair or unexplained adverse signals about employees | Medium | High | Advisory-only + human review (no automated adverse action); explanation + confidence mandatory; per-tenant opt-out; bias evaluation and model cards as release gates | ML eng + PM | + +## 11. Release & versioning strategy + +- **Trains.** Backend deploys continuously behind phase-gated feature flags (per-tenant enablement for payroll and face verification); Android ships a fortnightly train via Play staged rollout (1% → 10% → 50% → 100% with sync-health beacon monitoring at each step); Web Admin (from P3) deploys continuously to Firebase Hosting with preview channels per PR. +- **API compatibility.** `/v1` evolves additively only (master spec §3.4); the server supports the two previous Android train versions at all times; any breaking need opens a `/v2` discussion with an explicit ≥ 180-day deprecation window — no in-place breaks. +- **Feature flags.** Per-tenant flags gate P1+ features (kiosk, face, payroll, insights); flags are config on the Company document (`settingsJson`), read server-side; a flag removed only after two stable releases at 100%. +- **Data migrations.** Firestore schema changes are additive with lazy backfill jobs (Cloud Tasks batched, resumable); no release may require a stop-the-world migration; every backfill is idempotent and progress-checkpointed. +- **Rollback.** Backend: redeploy previous tag (no destructive migrations, so always safe). Android: halt staged rollout + server-side flag off; the offline outbox contract guarantees no data loss across app downgrades because queued ops target the stable `/v1` surface. + +## 12. Roadmap governance + +- **Phase gates.** A phase exits only when its exit criteria are demonstrably met; exit reviews are recorded and the master spec §8 is amended first if scope moves (per the spec's precedence rule). +- **Regression floor.** Every phase re-runs the P0 verification checklist (§2.2) plus prior phases' exit-criterion test suites; the sync contract and payroll parallel-run harness are permanent CI fixtures once introduced. +- **Change control.** Scope changes route through `00-master-spec.md` (canonical, update-first), then this roadmap, then the affected design docs — never the reverse. +- **Standing gates from P1 onward.** 100k synthetic-tenant load test; SLO burn-rate review (NFR-AVL/LAT budgets in `01-product-requirements.md` §6); DLQ-depth-zero check across all queues before release. +- **Risk review.** The register in §10 is reviewed at each phase gate; any risk trending to "High/Critical realized" freezes feature work in the owning workstream until a mitigation lands. From ef85531660da46f574a901947a2fd273e854071f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:48:45 +0000 Subject: [PATCH 002/139] build: Gradle 8.9 build system with convention plugins Version catalog, composite build-logic with convention plugins (application, library, compose, feature, hilt, room, jvm), and the pinned Gradle wrapper. minSdk 26 / target+compile SDK 35, Kotlin 2.0.20, AGP 8.5.2, Compose BOM 2024.09, Hilt 2.52, Room 2.6.1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- .gitignore | 25 ++ build-logic/convention/build.gradle.kts | 52 ++++ .../AndroidApplicationConventionPlugin.kt | 41 +++ .../kotlin/AndroidFeatureConventionPlugin.kt | 34 +++ .../kotlin/AndroidHiltConventionPlugin.kt | 18 ++ .../AndroidLibraryComposeConventionPlugin.kt | 16 ++ .../kotlin/AndroidLibraryConventionPlugin.kt | 30 +++ .../kotlin/AndroidRoomConventionPlugin.kt | 27 ++ .../main/kotlin/JvmLibraryConventionPlugin.kt | 30 +++ .../worktrack/buildlogic/AndroidCompose.kt | 27 ++ .../app/worktrack/buildlogic/KotlinAndroid.kt | 40 +++ .../worktrack/buildlogic/ProjectExtensions.kt | 10 + build-logic/settings.gradle.kts | 15 ++ build.gradle.kts | 14 + gradle.properties | 9 + gradle/libs.versions.toml | 133 ++++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 45 ++++ 21 files changed, 918 insertions(+) create mode 100644 .gitignore create mode 100644 build-logic/convention/build.gradle.kts create mode 100644 build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt create mode 100644 build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt create mode 100644 build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt create mode 100644 build-logic/settings.gradle.kts create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..274b97c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.DS_Store +local.properties +captures/ +.externalNativeBuild/ +.cxx/ + +# Secrets / environment-specific configuration (never commit) +app/google-services.json +backend/.firebaserc +backend/functions/.env* +*.keystore +*.jks + +# Node +node_modules/ +backend/functions/lib/ +npm-debug.log* diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000..cfa987f --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + `kotlin-dsl` +} + +group = "app.worktrack.buildlogic" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +dependencies { + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.ksp.gradle.plugin) + compileOnly(libs.compose.compiler.gradle.plugin) + compileOnly(libs.room.gradle.plugin) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "worktrack.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidLibrary") { + id = "worktrack.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidLibraryCompose") { + id = "worktrack.android.library.compose" + implementationClass = "AndroidLibraryComposeConventionPlugin" + } + register("androidFeature") { + id = "worktrack.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidHilt") { + id = "worktrack.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" + } + register("androidRoom") { + id = "worktrack.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("jvmLibrary") { + id = "worktrack.jvm.library" + implementationClass = "JvmLibraryConventionPlugin" + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt new file mode 100644 index 0000000..582bd02 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,41 @@ +import app.worktrack.buildlogic.configureAndroidCompose +import app.worktrack.buildlogic.configureKotlinAndroid +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + configureKotlinAndroid(this) + configureAndroidCompose(this) + + defaultConfig { + targetSdk = 35 + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt new file mode 100644 index 0000000..c4b0e68 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -0,0 +1,34 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +/** + * Standard setup for feature modules: Compose library + Hilt + the dependency set + * every screen needs (domain contracts, design system, lifecycle, navigation). + */ +class AndroidFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("worktrack.android.library.compose") + pluginManager.apply("worktrack.android.hilt") + + dependencies { + "implementation"(project(":core:common")) + "implementation"(project(":core:model")) + "implementation"(project(":core:domain")) + "implementation"(project(":core:designsystem")) + + "implementation"(libs.findLibrary("androidx-lifecycle-runtime-compose").get()) + "implementation"(libs.findLibrary("androidx-lifecycle-viewmodel-compose").get()) + "implementation"(libs.findLibrary("androidx-navigation-compose").get()) + "implementation"(libs.findLibrary("hilt-navigation-compose").get()) + "implementation"(libs.findLibrary("kotlinx-coroutines-android").get()) + "implementation"(libs.findLibrary("androidx-compose-material-icons").get()) + + "testImplementation"(libs.findLibrary("turbine").get()) + "testImplementation"(libs.findLibrary("mockk").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt new file mode 100644 index 0000000..b6d9d95 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -0,0 +1,18 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + pluginManager.apply("com.google.dagger.hilt.android") + + dependencies { + "implementation"(libs.findLibrary("hilt-android").get()) + "ksp"(libs.findLibrary("hilt-compiler").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt new file mode 100644 index 0000000..24bb9b6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -0,0 +1,16 @@ +import app.worktrack.buildlogic.configureAndroidCompose +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidLibraryComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("worktrack.android.library") + extensions.configure { + configureAndroidCompose(this) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..7731063 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,30 @@ +import app.worktrack.buildlogic.configureKotlinAndroid +import app.worktrack.buildlogic.libs +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + configureKotlinAndroid(this) + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + dependencies { + "testImplementation"(libs.findLibrary("junit4").get()) + "testImplementation"(libs.findLibrary("kotlinx-coroutines-test").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt new file mode 100644 index 0000000..8c496ec --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -0,0 +1,27 @@ +import app.worktrack.buildlogic.libs +import androidx.room.gradle.RoomExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("androidx.room") + pluginManager.apply("com.google.devtools.ksp") + + // Exported schemas are the migration contract; they are version-controlled. + extensions.configure { + schemaDirectory("$projectDir/schemas") + } + + dependencies { + "implementation"(libs.findLibrary("room-runtime").get()) + "implementation"(libs.findLibrary("room-ktx").get()) + "ksp"(libs.findLibrary("room-compiler").get()) + "testImplementation"(libs.findLibrary("room-testing").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt new file mode 100644 index 0000000..77f25e0 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt @@ -0,0 +1,30 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +/** + * Pure-JVM Kotlin module: fastest to compile and enforces that domain logic + * stays free of Android framework types. + */ +class JvmLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.jvm") + + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + + dependencies { + "testImplementation"(libs.findLibrary("junit4").get()) + "testImplementation"(libs.findLibrary("kotlinx-coroutines-test").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt new file mode 100644 index 0000000..7c75411 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt @@ -0,0 +1,27 @@ +package app.worktrack.buildlogic + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +/** Enables Jetpack Compose with the shared BOM and tooling wiring. */ +internal fun Project.configureAndroidCompose(commonExtension: CommonExtension<*, *, *, *, *, *>) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + commonExtension.apply { + buildFeatures { + compose = true + } + } + + dependencies { + val bom = libs.findLibrary("androidx-compose-bom").get() + "implementation"(platform(bom)) + "androidTestImplementation"(platform(bom)) + "implementation"(libs.findLibrary("androidx-compose-ui").get()) + "implementation"(libs.findLibrary("androidx-compose-ui-graphics").get()) + "implementation"(libs.findLibrary("androidx-compose-material3").get()) + "implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get()) + "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get()) + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt new file mode 100644 index 0000000..5808060 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt @@ -0,0 +1,40 @@ +package app.worktrack.buildlogic + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +/** + * Baseline Android + Kotlin configuration shared by every Android module. + * + * minSdk 26 gives us java.time and modern security APIs without desugaring; + * WorkTrack targets managed corporate devices where API 26+ coverage is near-total. + */ +internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { + commonExtension.apply { + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + + extensions.configure { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt new file mode 100644 index 0000000..9dc3caa --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt @@ -0,0 +1,10 @@ +package app.worktrack.buildlogic + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +/** Typed access to the shared version catalog from within convention plugins. */ +val Project.libs: VersionCatalog + get() = extensions.getByType().named("libs") diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..875164f --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..807aeff --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,14 @@ +// Root build file: plugin versions are resolved here once so that all modules +// share a single, consistent toolchain. Convention plugins in build-logic apply them. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.room) apply false + alias(libs.plugins.google.services) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5a14591 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..683fa0c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,133 @@ +[versions] +agp = "8.5.2" +kotlin = "2.0.20" +ksp = "2.0.20-1.0.25" +coroutines = "1.9.0" +kotlinxSerialization = "1.7.3" + +androidxCore = "1.13.1" +androidxLifecycle = "2.8.6" +androidxActivity = "1.9.2" +composeBom = "2024.09.03" +navigationCompose = "2.8.1" +hilt = "2.52" +hiltExt = "1.2.0" +room = "2.6.1" +work = "2.9.1" +datastore = "1.1.1" + +retrofit = "2.11.0" +okhttp = "4.12.0" +coil = "2.7.0" + +firebaseBom = "33.3.0" +googleServices = "4.4.2" +playServicesLocation = "21.3.0" +mlkitBarcode = "17.3.0" +camerax = "1.3.4" + +javaxInject = "1" + +junit = "4.13.2" +turbine = "1.1.0" +mockk = "1.13.12" +androidxTestExt = "1.2.1" +androidxTestRunner = "1.6.2" + +[libraries] +# Kotlin / coroutines / serialization +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# AndroidX core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } + +# Compose +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } +hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltExt" } + +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +# WorkManager / DataStore +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Network +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } +okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Images +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Firebase / Google Play services / ML Kit +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-auth = { group = "com.google.firebase", name = "firebase-auth-ktx" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } + +# Misc +javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } + +# Testing +junit4 = { group = "junit", name = "junit", version.ref = "junit" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } + +# Dependencies used by build-logic convention plugins +android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +ksp-gradle-plugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +room-gradle-plugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +room = { id = "androidx.room", version.ref = "room" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } + +# Convention plugins exposed by build-logic +worktrack-android-application = { id = "worktrack.android.application" } +worktrack-android-library = { id = "worktrack.android.library" } +worktrack-android-library-compose = { id = "worktrack.android.library.compose" } +worktrack-android-feature = { id = "worktrack.android.feature" } +worktrack-android-hilt = { id = "worktrack.android.hilt" } +worktrack-android-room = { id = "worktrack.android.room" } +worktrack-jvm-library = { id = "worktrack.jvm.library" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 zcma&OWmKeVvL#I6?i3D%6z=Zs?ofE*?rw#G$eqJB ziT4y8-Y@s9rkH0Tz>ll(^xkcTl)CY?rS&9VNd66Yc)g^6)JcWaY(5$5gt z8gr3SBXUTN;~cBgz&})qX%#!Fxom2Yau_`&8)+6aSN7YY+pS410rRUU*>J}qL0TnJ zRxt*7QeUqTh8j)Q&iavh<}L+$Jqz))<`IfKussVk%%Ah-Ti?Eo0hQH!rK%K=#EAw0 zwq@@~XNUXRnv8$;zv<6rCRJ6fPD^hfrh;0K?n z=p!u^3xOgWZ%f3+?+>H)9+w^$Tn1e;?UpVMJb!!;f)`6f&4|8mr+g)^@x>_rvnL0< zvD0Hu_N>$(Li7|Jgu0mRh&MV+<}`~Wi*+avM01E)Jtg=)-vViQKax!GeDc!xv$^mL z{#OVBA$U{(Zr8~Xm|cP@odkHC*1R8z6hcLY#N@3E-A8XEvpt066+3t9L_6Zg6j@9Q zj$$%~yO-OS6PUVrM2s)(T4#6=JpI_@Uz+!6=GdyVU?`!F=d;8#ZB@(5g7$A0(`eqY z8_i@3w$0*es5mrSjhW*qzrl!_LQWs4?VfLmo1Sd@Ztt53+etwzAT^8ow_*7Jp`Y|l z*UgSEwvxq+FYO!O*aLf-PinZYne7Ib6ny3u>MjQz=((r3NTEeU4=-i0LBq3H-VJH< z^>1RE3_JwrclUn9vb7HcGUaFRA0QHcnE;6)hnkp%lY1UII#WPAv?-;c?YH}LWB8Nl z{sx-@Z;QxWh9fX8SxLZk8;kMFlGD3Jc^QZVL4nO)1I$zQwvwM&_!kW+LMf&lApv#< zur|EyC|U@5OQuph$TC_ZU`{!vJp`13e9alaR0Dbn5ikLFH7>eIz4QbV|C=%7)F=qo z_>M&5N)d)7G(A%c>}UCrW!Ql_6_A{?R7&CL`;!KOb3 z8Z=$YkV-IF;c7zs{3-WDEFJzuakFbd*4LWd<_kBE8~BFcv}js_2OowRNzWCtCQ6&k z{&~Me92$m*@e0ANcWKuz)?YjB*VoSTx??-3Cc0l2U!X^;Bv@m87eKHukAljrD54R+ zE;@_w4NPe1>3`i5Qy*3^E9x#VB6?}v=~qIprrrd5|DFkg;v5ixo0IsBmik8=Y;zv2 z%Bcf%NE$a44bk^`i4VwDLTbX=q@j9;JWT9JncQ!+Y%2&HHk@1~*L8-{ZpY?(-a9J-1~<1ltr9i~D9`P{XTIFWA6IG8c4;6bFw*lzU-{+?b&%OcIoCiw00n>A1ra zFPE$y@>ebbZlf(sN_iWBzQKDV zmmaLX#zK!@ZdvCANfwV}9@2O&w)!5gSgQzHdk2Q`jG6KD7S+1R5&F)j6QTD^=hq&7 zHUW+r^da^%V(h(wonR(j?BOiC!;y=%nJvz?*aW&5E87qq;2z`EI(f zBJNNSMFF9U{sR-af5{IY&AtoGcoG)Iq-S^v{7+t0>7N(KRoPj;+2N5;9o_nxIGjJ@ z7bYQK)bX)vEhy~VL%N6g^NE@D5VtV+Q8U2%{ji_=6+i^G%xeskEhH>Sqr194PJ$fB zu1y^){?9Vkg(FY2h)3ZHrw0Z<@;(gd_dtF#6y_;Iwi{yX$?asr?0N0_B*CifEi7<6 zq`?OdQjCYbhVcg+7MSgIM|pJRu~`g?g3x?Tl+V}#$It`iD1j+!x+!;wS0+2e>#g?Z z*EA^k7W{jO1r^K~cD#5pamp+o@8&yw6;%b|uiT?{Wa=4+9<}aXWUuL#ZwN1a;lQod zW{pxWCYGXdEq9qAmvAB904}?97=re$>!I%wxPV#|f#@A*Y=qa%zHlDv^yWbR03%V0 zprLP+b(#fBqxI%FiF*-n8HtH6$8f(P6!H3V^ysgd8de-N(@|K!A< z^qP}jp(RaM9kQ(^K(U8O84?D)aU(g?1S8iWwe)gqpHCaFlJxb*ilr{KTnu4_@5{K- z)n=CCeCrPHO0WHz)dDtkbZfUfVBd?53}K>C5*-wC4hpDN8cGk3lu-ypq+EYpb_2H; z%vP4@&+c2p;thaTs$dc^1CDGlPG@A;yGR5@$UEqk6p58qpw#7lc<+W(WR;(vr(D>W z#(K$vE#uBkT=*q&uaZwzz=P5mjiee6>!lV?c}QIX%ZdkO1dHg>Fa#xcGT6~}1*2m9 zkc7l3ItD6Ie~o_aFjI$Ri=C!8uF4!Ky7iG9QTrxVbsQroi|r)SAon#*B*{}TB-?=@ z8~jJs;_R2iDd!$+n$%X6FO&PYS{YhDAS+U2o4su9x~1+U3z7YN5o0qUK&|g^klZ6X zj_vrM5SUTnz5`*}Hyts9ADwLu#x_L=nv$Z0`HqN`Zo=V>OQI)fh01n~*a%01%cx%0 z4LTFVjmW+ipVQv5rYcn3;d2o4qunWUY!p+?s~X~(ost@WR@r@EuDOSs8*MT4fiP>! zkfo^!PWJJ1MHgKS2D_hc?Bs?isSDO61>ebl$U*9*QY(b=i&rp3@3GV@z>KzcZOxip z^dzA~44;R~cnhWz7s$$v?_8y-k!DZys}Q?4IkSyR!)C0j$(Gm|t#e3|QAOFaV2}36 z?dPNY;@I=FaCwylc_;~kXlZsk$_eLkNb~TIl8QQ`mmH&$*zwwR8zHU*sId)rxHu*K z;yZWa8UmCwju%aSNLwD5fBl^b0Ux1%q8YR*uG`53Mi<`5uA^Dc6Ync)J3N7;zQ*75)hf%a@{$H+%S?SGT)ks60)?6j$ zspl|4Ad6@%-r1t*$tT(en!gIXTUDcsj?28ZEzz)dH)SV3bZ+pjMaW0oc~rOPZP@g! zb9E+ndeVO_Ib9c_>{)`01^`ZS198 z)(t=+{Azi11$eu%aU7jbwuQrO`vLOixuh~%4z@mKr_Oc;F%Uq01fA)^W&y+g16e?rkLhTxV!EqC%2}sx_1u7IBq|}Be&7WI z4I<;1-9tJsI&pQIhj>FPkQV9{(m!wYYV@i5h?A0#BN2wqlEwNDIq06|^2oYVa7<~h zI_OLan0Do*4R5P=a3H9`s5*>xU}_PSztg`+2mv)|3nIy=5#Z$%+@tZnr> zLcTI!Mxa`PY7%{;KW~!=;*t)R_sl<^b>eNO@w#fEt(tPMg_jpJpW$q_DoUlkY|uo> z0-1{ouA#;t%spf*7VjkK&$QrvwUERKt^Sdo)5@?qAP)>}Y!h4(JQ!7{wIdkA+|)bv z&8hBwoX4v|+fie}iTslaBX^i*TjwO}f{V)8*!dMmRPi%XAWc8<_IqK1jUsApk)+~R zNFTCD-h>M5Y{qTQ&0#j@I@tmXGj%rzhTW5%Bkh&sSc=$Fv;M@1y!zvYG5P2(2|(&W zlcbR1{--rJ&s!rB{G-sX5^PaM@3EqWVz_y9cwLR9xMig&9gq(voeI)W&{d6j1jh&< zARXi&APWE1FQWh7eoZjuP z;vdgX>zep^{{2%hem;e*gDJhK1Hj12nBLIJoL<=0+8SVEBx7!4Ea+hBY;A1gBwvY<)tj~T=H`^?3>zeWWm|LAwo*S4Z%bDVUe z6r)CH1H!(>OH#MXFJ2V(U(qxD{4Px2`8qfFLG+=a;B^~Te_Z!r3RO%Oc#ZAHKQxV5 zRYXxZ9T2A%NVJIu5Pu7!Mj>t%YDO$T@M=RR(~mi%sv(YXVl`yMLD;+WZ{vG9(@P#e zMo}ZiK^7^h6TV%cG+;jhJ0s>h&VERs=tuZz^Tlu~%d{ZHtq6hX$V9h)Bw|jVCMudd zwZ5l7In8NT)qEPGF$VSKg&fb0%R2RnUnqa){)V(X(s0U zkCdVZe6wy{+_WhZh3qLp245Y2RR$@g-!9PjJ&4~0cFSHMUn=>dapv)hy}|y91ZWTV zCh=z*!S3_?`$&-eZ6xIXUq8RGl9oK0BJw*TdU6A`LJqX9eS3X@F)g$jLkBWFscPhR zpCv8#KeAc^y>>Y$k^=r|K(DTC}T$0#jQBOwB#@`P6~*IuW_8JxCG}J4va{ zsZzt}tt+cv7=l&CEuVtjD6G2~_Meh%p4RGuY?hSt?(sreO_F}8r7Kp$qQdvCdZnDQ zxzc*qchE*E2=WK)^oRNa>Ttj`fpvF-JZ5tu5>X1xw)J@1!IqWjq)ESBG?J|ez`-Tc zi5a}GZx|w-h%5lNDE_3ho0hEXMoaofo#Z;$8|2;EDF&*L+e$u}K=u?pb;dv$SXeQM zD-~7P0i_`Wk$#YP$=hw3UVU+=^@Kuy$>6?~gIXx636jh{PHly_a2xNYe1l60`|y!7 z(u%;ILuW0DDJ)2%y`Zc~hOALnj1~txJtcdD#o4BCT68+8gZe`=^te6H_egxY#nZH&P*)hgYaoJ^qtmpeea`35Fw)cy!w@c#v6E29co8&D9CTCl%^GV|X;SpneSXzV~LXyRn-@K0Df z{tK-nDWA!q38M1~`xUIt_(MO^R(yNY#9@es9RQbY@Ia*xHhD&=k^T+ zJi@j2I|WcgW=PuAc>hs`(&CvgjL2a9Rx zCbZyUpi8NWUOi@S%t+Su4|r&UoU|ze9SVe7p@f1GBkrjkkq)T}X%Qo1g!SQ{O{P?m z-OfGyyWta+UCXH+-+(D^%kw#A1-U;?9129at7MeCCzC{DNgO zeSqsV>W^NIfTO~4({c}KUiuoH8A*J!Cb0*sp*w-Bg@YfBIPZFH!M}C=S=S7PLLcIG zs7K77g~W)~^|+mx9onzMm0qh(f~OsDTzVmRtz=aZTllgR zGUn~_5hw_k&rll<4G=G+`^Xlnw;jNYDJz@bE?|r866F2hA9v0-8=JO3g}IHB#b`hy zA42a0>{0L7CcabSD+F7?pGbS1KMvT{@1_@k!_+Ki|5~EMGt7T%u=79F)8xEiL5!EJ zzuxQ`NBliCoJMJdwu|);zRCD<5Sf?Y>U$trQ-;xj6!s5&w=9E7)%pZ+1Nh&8nCCwM zv5>Ket%I?cxr3vVva`YeR?dGxbG@pi{H#8@kFEf0Jq6~K4>kt26*bxv=P&jyE#e$| zDJB_~imk^-z|o!2njF2hL*|7sHCnzluhJjwLQGDmC)Y9 zr9ZN`s)uCd^XDvn)VirMgW~qfn1~SaN^7vcX#K1G`==UGaDVVx$0BQnubhX|{e z^i0}>k-;BP#Szk{cFjO{2x~LjK{^Upqd&<+03_iMLp0$!6_$@TbX>8U-f*-w-ew1?`CtD_0y_Lo|PfKi52p?`5$Jzx0E8`M0 zNIb?#!K$mM4X%`Ry_yhG5k@*+n4||2!~*+&pYLh~{`~o(W|o64^NrjP?-1Lgu?iK^ zTX6u3?#$?R?N!{599vg>G8RGHw)Hx&=|g4599y}mXNpM{EPKKXB&+m?==R3GsIq?G zL5fH={=zawB(sMlDBJ+{dgb)Vx3pu>L=mDV0{r1Qs{0Pn%TpopH{m(By4;{FBvi{I z$}x!Iw~MJOL~&)p93SDIfP3x%ROjg}X{Sme#hiJ&Yk&a;iR}V|n%PriZBY8SX2*;6 z4hdb^&h;Xz%)BDACY5AUsV!($lib4>11UmcgXKWpzRL8r2Srl*9Y(1uBQsY&hO&uv znDNff0tpHlLISam?o(lOp#CmFdH<6HmA0{UwfU#Y{8M+7od8b8|B|7ZYR9f<#+V|ZSaCQvI$~es~g(Pv{2&m_rKSB2QQ zMvT}$?Ll>V+!9Xh5^iy3?UG;dF-zh~RL#++roOCsW^cZ&({6q|?Jt6`?S8=16Y{oH zp50I7r1AC1(#{b`Aq5cw>ypNggHKM9vBx!W$eYIzD!4KbLsZGr2o8>g<@inmS3*>J zx8oG((8f!ei|M@JZB`p7+n<Q}?>h249<`7xJ?u}_n;Gq(&km#1ULN87CeTO~FY zS_Ty}0TgQhV zOh3T7{{x&LSYGQfKR1PDIkP!WnfC1$l+fs@Di+d4O=eVKeF~2fq#1<8hEvpwuqcaH z4A8u~r^gnY3u6}zj*RHjk{AHhrrDqaj?|6GaVJbV%o-nATw}ASFr!f`Oz|u_QPkR# z0mDudY1dZRlk@TyQ?%Eti=$_WNFtLpSx9=S^be{wXINp%MU?a`F66LNU<c;0&ngifmP9i;bj6&hdGMW^Kf8e6ZDXbQD&$QAAMo;OQ)G zW(qlHh;}!ZP)JKEjm$VZjTs@hk&4{?@+NADuYrr!R^cJzU{kGc1yB?;7mIyAWwhbeA_l_lw-iDVi7wcFurf5 z#Uw)A@a9fOf{D}AWE%<`s1L_AwpZ?F!Vac$LYkp<#A!!`XKaDC{A%)~K#5z6>Hv@V zBEqF(D5?@6r3Pwj$^krpPDCjB+UOszqUS;b2n>&iAFcw<*im2(b3|5u6SK!n9Sg4I z0KLcwA6{Mq?p%t>aW0W!PQ>iUeYvNjdKYqII!CE7SsS&Rj)eIw-K4jtI?II+0IdGq z2WT|L3RL?;GtGgt1LWfI4Ka`9dbZXc$TMJ~8#Juv@K^1RJN@yzdLS8$AJ(>g!U9`# zx}qr7JWlU+&m)VG*Se;rGisutS%!6yybi%B`bv|9rjS(xOUIvbNz5qtvC$_JYY+c& za*3*2$RUH8p%pSq>48xR)4qsp!Q7BEiJ*`^>^6INRbC@>+2q9?x(h0bpc>GaNFi$K zPH$6!#(~{8@0QZk=)QnM#I=bDx5vTvjm$f4K}%*s+((H2>tUTf==$wqyoI`oxI7>C z&>5fe)Yg)SmT)eA(|j@JYR1M%KixxC-Eceknf-;N=jJTwKvk#@|J^&5H0c+%KxHUI z6dQbwwVx3p?X<_VRVb2fStH?HH zFR@Mp=qX%#L3XL)+$PXKV|o|#DpHAoqvj6uQKe@M-mnhCSou7Dj4YuO6^*V`m)1lf z;)@e%1!Qg$10w8uEmz{ENb$^%u}B;J7sDd zump}onoD#!l=agcBR)iG!3AF0-63%@`K9G(CzKrm$VJ{v7^O9Ps7Zej|3m= zVXlR&yW6=Y%mD30G@|tf=yC7-#L!16Q=dq&@beWgaIL40k0n% z)QHrp2Jck#evLMM1RGt3WvQ936ZC9vEje0nFMfvmOHVI+&okB_K|l-;|4vW;qk>n~ z+|kk8#`K?x`q>`(f6A${wfw9Cx(^)~tX7<#TpxR#zYG2P+FY~mG{tnEkv~d6oUQA+ z&hNTL=~Y@rF`v-RZlts$nb$3(OL1&@Y11hhL9+zUb6)SP!;CD)^GUtUpCHBE`j1te zAGud@miCVFLk$fjsrcpjsadP__yj9iEZUW{Ll7PPi<$R;m1o!&Xdl~R_v0;oDX2z^!&8}zNGA}iYG|k zmehMd1%?R)u6R#<)B)1oe9TgYH5-CqUT8N7K-A-dm3hbm_W21p%8)H{O)xUlBVb+iUR}-v5dFaCyfSd zC6Bd7=N4A@+Bna=!-l|*_(nWGDpoyU>nH=}IOrLfS+-d40&(Wo*dDB9nQiA2Tse$R z;uq{`X7LLzP)%Y9aHa4YQ%H?htkWd3Owv&UYbr5NUDAH^<l@Z0Cx%`N+B*i!!1u>D8%;Qt1$ zE5O0{-`9gdDxZ!`0m}ywH!;c{oBfL-(BH<&SQ~smbcobU!j49O^f4&IIYh~f+hK*M zZwTp%{ZSAhMFj1qFaOA+3)p^gnXH^=)`NTYgTu!CLpEV2NF=~-`(}7p^Eof=@VUbd z_9U|8qF7Rueg&$qpSSkN%%%DpbV?8E8ivu@ensI0toJ7Eas^jyFReQ1JeY9plb^{m z&eQO)qPLZQ6O;FTr*aJq=$cMN)QlQO@G&%z?BKUs1&I^`lq>=QLODwa`(mFGC`0H< zOlc*|N?B5&!U6BuJvkL?s1&nsi$*5cCv7^j_*l&$-sBmRS85UIrE--7eD8Gr3^+o? zqG-Yl4S&E;>H>k^a0GdUI(|n1`ws@)1%sq2XBdK`mqrNq_b4N{#VpouCXLzNvjoFv zo9wMQ6l0+FT+?%N(ka*;%m~(?338bu32v26!{r)|w8J`EL|t$}TA4q_FJRX5 zCPa{hc_I(7TGE#@rO-(!$1H3N-C0{R$J=yPCXCtGk{4>=*B56JdXU9cQVwB`6~cQZ zf^qK21x_d>X%dT!!)CJQ3mlHA@ z{Prkgfs6=Tz%63$6Zr8CO0Ak3A)Cv#@BVKr&aiKG7RYxY$Yx>Bj#3gJk*~Ps-jc1l z;4nltQwwT4@Z)}Pb!3xM?+EW0qEKA)sqzw~!C6wd^{03-9aGf3Jmt=}w-*!yXupLf z;)>-7uvWN4Unn8b4kfIza-X=x*e4n5pU`HtgpFFd))s$C@#d>aUl3helLom+RYb&g zI7A9GXLRZPl}iQS*d$Azxg-VgcUr*lpLnbPKUV{QI|bsG{8bLG<%CF( zMoS4pRDtLVYOWG^@ox^h8xL~afW_9DcE#^1eEC1SVSb1BfDi^@g?#f6e%v~Aw>@w- zIY0k+2lGWNV|aA*e#`U3=+oBDmGeInfcL)>*!w|*;mWiKNG6wP6AW4-4imN!W)!hE zA02~S1*@Q`fD*+qX@f3!2yJX&6FsEfPditB%TWo3=HA;T3o2IrjS@9SSxv%{{7&4_ zdS#r4OU41~GYMiib#z#O;zohNbhJknrPPZS6sN$%HB=jUnlCO_w5Gw5EeE@KV>soy z2EZ?Y|4RQDDjt5y!WBlZ(8M)|HP<0YyG|D%RqD+K#e7-##o3IZxS^wQ5{Kbzb6h(i z#(wZ|^ei>8`%ta*!2tJzwMv+IFHLF`zTU8E^Mu!R*45_=ccqI};Zbyxw@U%a#2}%f zF>q?SrUa_a4H9l+uW8JHh2Oob>NyUwG=QH~-^ZebU*R@67DcXdz2{HVB4#@edz?B< z5!rQH3O0>A&ylROO%G^fimV*LX7>!%re{_Sm6N>S{+GW1LCnGImHRoF@csnFzn@P0 zM=jld0z%oz;j=>c7mMwzq$B^2mae7NiG}%>(wtmsDXkWk{?BeMpTrIt3Mizq?vRsf zi_WjNp+61uV(%gEU-Vf0;>~vcDhe(dzWdaf#4mH3o^v{0EWhj?E?$5v02sV@xL0l4 zX0_IMFtQ44PfWBbPYN#}qxa%=J%dlR{O!KyZvk^g5s?sTNycWYPJ^FK(nl3k?z-5t z39#hKrdO7V(@!TU)LAPY&ngnZ1MzLEeEiZznn7e-jLCy8LO zu^7_#z*%I-BjS#Pg-;zKWWqX-+Ly$T!4`vTe5ZOV0j?TJVA*2?*=82^GVlZIuH%9s zXiV&(T(QGHHah=s&7e|6y?g+XxZGmK55`wGV>@1U)Th&=JTgJq>4mI&Av2C z)w+kRoj_dA!;SfTfkgMPO>7Dw6&1*Hi1q?54Yng`JO&q->^CX21^PrU^JU#CJ_qhV zSG>afB%>2fx<~g8p=P8Yzxqc}s@>>{g7}F!;lCXvF#RV)^fyYb_)iKVCz1xEq=fJ| z0a7DMCK*FuP=NM*5h;*D`R4y$6cpW-E&-i{v`x=Jbk_xSn@2T3q!3HoAOB`@5Vg6) z{PW|@9o!e;v1jZ2{=Uw6S6o{g82x6g=k!)cFSC*oemHaVjg?VpEmtUuD2_J^A~$4* z3O7HsbA6wxw{TP5Kk)(Vm?gKo+_}11vbo{Tp_5x79P~#F)ahQXT)tSH5;;14?s)On zel1J>1x>+7;g1Iz2FRpnYz;sD0wG9Q!vuzE9yKi3@4a9Nh1!GGN?hA)!mZEnnHh&i zf?#ZEN2sFbf~kV;>K3UNj1&vFhc^sxgj8FCL4v>EOYL?2uuT`0eDH}R zmtUJMxVrV5H{L53hu3#qaWLUa#5zY?f5ozIn|PkMWNP%n zWB5!B0LZB0kLw$k39=!akkE9Q>F4j+q434jB4VmslQ;$ zKiO#FZ`p|dKS716jpcvR{QJkSNfDVhr2%~eHrW;fU45>>snr*S8Vik-5eN5k*c2Mp zyxvX&_cFbB6lODXznHHT|rsURe2!swomtrqc~w5 zymTM8!w`1{04CBprR!_F{5LB+2_SOuZN{b*!J~1ZiPpP-M;);!ce!rOPDLtgR@Ie1 zPreuqm4!H)hYePcW1WZ0Fyaqe%l}F~Orr)~+;mkS&pOhP5Ebb`cnUt!X_QhP4_4p( z8YKQCDKGIy>?WIFm3-}Br2-N`T&FOi?t)$hjphB9wOhBXU#Hb+zm&We_-O)s(wc`2 z8?VsvU;J>Ju7n}uUb3s1yPx_F*|FlAi=Ge=-kN?1;`~6szP%$3B0|8Sqp%ebM)F8v zADFrbeT0cgE>M0DMV@_Ze*GHM>q}wWMzt|GYC%}r{OXRG3Ij&<+nx9;4jE${Fj_r* z`{z1AW_6Myd)i6e0E-h&m{{CvzH=Xg!&(bLYgRMO_YVd8JU7W+7MuGWNE=4@OvP9+ zxi^vqS@5%+#gf*Z@RVyU9N1sO-(rY$24LGsg1>w>s6ST^@)|D9>cT50maXLUD{Fzf zt~tp{OSTEKg3ZSQyQQ5r51){%=?xlZ54*t1;Ow)zLe3i?8tD8YyY^k%M)e`V*r+vL zPqUf&m)U+zxps+NprxMHF{QSxv}>lE{JZETNk1&F+R~bp{_T$dbXL2UGnB|hgh*p4h$clt#6;NO~>zuyY@C-MD@)JCc5XrYOt`wW7! z_ti2hhZBMJNbn0O-uTxl_b6Hm313^fG@e;RrhIUK9@# z+DHGv_Ow$%S8D%RB}`doJjJy*aOa5mGHVHz0e0>>O_%+^56?IkA5eN+L1BVCp4~m=1eeL zb;#G!#^5G%6Mw}r1KnaKsLvJB%HZL)!3OxT{k$Yo-XrJ?|7{s4!H+S2o?N|^Z z)+?IE9H7h~Vxn5hTis^3wHYuOU84+bWd)cUKuHapq=&}WV#OxHpLab`NpwHm8LmOo zjri+!k;7j_?FP##CpM+pOVx*0wExEex z@`#)K<-ZrGyArK;a%Km`^+We|eT+#MygHOT6lXBmz`8|lyZOwL1+b+?Z$0OhMEp3R z&J=iRERpv~TC=p2-BYLC*?4 zxvPs9V@g=JT0>zky5Poj=fW_M!c)Xxz1<=&_ZcL=LMZJqlnO1P^xwGGW*Z+yTBvbV z-IFe6;(k1@$1;tS>{%pXZ_7w+i?N4A2=TXnGf=YhePg8bH8M|Lk-->+w8Y+FjZ;L=wSGwxfA`gqSn)f(XNuSm>6Y z@|#e-)I(PQ^G@N`%|_DZSb4_pkaEF0!-nqY+t#pyA>{9^*I-zw4SYA1_z2Bs$XGUZbGA;VeMo%CezHK0lO={L%G)dI-+8w?r9iexdoB{?l zbJ}C?huIhWXBVs7oo{!$lOTlvCLZ_KN1N+XJGuG$rh<^eUQIqcI7^pmqhBSaOKNRq zrx~w^?9C?*&rNwP_SPYmo;J-#!G|{`$JZK7DxsM3N^8iR4vvn>E4MU&Oe1DKJvLc~ zCT>KLZ1;t@My zRj_2hI^61T&LIz)S!+AQIV23n1>ng+LUvzv;xu!4;wpqb#EZz;F)BLUzT;8UA1x*6vJ zicB!3Mj03s*kGV{g`fpC?V^s(=JG-k1EMHbkdP4P*1^8p_TqO|;!Zr%GuP$8KLxuf z=pv*H;kzd;P|2`JmBt~h6|GxdU~@weK5O=X&5~w$HpfO}@l-T7@vTCxVOwCkoPQv8 z@aV_)I5HQtfs7^X=C03zYmH4m0S!V@JINm6#(JmZRHBD?T!m^DdiZJrhKpBcur2u1 zf9e4%k$$vcFopK5!CC`;ww(CKL~}mlxK_Pv!cOsFgVkNIghA2Au@)t6;Y3*2gK=5d z?|@1a)-(sQ%uFOmJ7v2iG&l&m^u&^6DJM#XzCrF%r>{2XKyxLD2rgWBD;i(!e4InDQBDg==^z;AzT2z~OmV0!?Z z0S9pX$+E;w3WN;v&NYT=+G8hf=6w0E1$0AOr61}eOvE8W1jX%>&Mjo7&!ulawgzLH zbcb+IF(s^3aj12WSi#pzIpijJJzkP?JzRawnxmNDSUR#7!29vHULCE<3Aa#be}ie~d|!V+ z%l~s9Odo$G&fH!t!+`rUT0T9DulF!Yq&BfQWFZV1L9D($r4H(}Gnf6k3^wa7g5|Ws zj7%d`!3(0bb55yhC6@Q{?H|2os{_F%o=;-h{@Yyyn*V7?{s%Grvpe!H^kl6tF4Zf5 z{Jv1~yZ*iIWL_9C*8pBMQArfJJ0d9Df6Kl#wa}7Xa#Ef_5B7=X}DzbQXVPfCwTO@9+@;A^Ti6il_C>g?A-GFwA0#U;t4;wOm-4oS})h z5&on>NAu67O?YCQr%7XIzY%LS4bha9*e*4bU4{lGCUmO2UQ2U)QOqClLo61Kx~3dI zmV3*(P6F_Tr-oP%x!0kTnnT?Ep5j;_IQ^pTRp=e8dmJtI4YgWd0}+b2=ATkOhgpXe z;jmw+FBLE}UIs4!&HflFr4)vMFOJ19W4f2^W(=2)F%TAL)+=F>IE$=e=@j-*bFLSg z)wf|uFQu+!=N-UzSef62u0-C8Zc7 zo6@F)c+nZA{H|+~7i$DCU0pL{0Ye|fKLuV^w!0Y^tT$isu%i1Iw&N|tX3kwFKJN(M zXS`k9js66o$r)x?TWL}Kxl`wUDUpwFx(w4Yk%49;$sgVvT~n8AgfG~HUcDt1TRo^s zdla@6heJB@JV z!vK;BUMznhzGK6PVtj0)GB=zTv6)Q9Yt@l#fv7>wKovLobMV-+(8)NJmyF8R zcB|_K7=FJGGn^X@JdFaat0uhKjp3>k#^&xE_}6NYNG?kgTp>2Iu?ElUjt4~E-?`Du z?mDCS9wbuS%fU?5BU@Ijx>1HG*N?gIP+<~xE4u=>H`8o((cS5M6@_OK%jSjFHirQK zN9@~NXFx*jS{<|bgSpC|SAnA@I)+GB=2W|JJChLI_mx+-J(mSJ!b)uUom6nH0#2^(L@JBlV#t zLl?j54s`Y3vE^c_3^Hl0TGu*tw_n?@HyO@ZrENxA+^!)OvUX28gDSF*xFtQzM$A+O zCG=n#6~r|3zt=8%GuG} z<#VCZ%2?3Q(Ad#Y7GMJ~{U3>E{5e@z6+rgZLX{Cxk^p-7dip^d29;2N1_mm4QkASo z-L`GWWPCq$uCo;X_BmGIpJFBlhl<8~EG{vOD1o|X$aB9KPhWO_cKiU*$HWEgtf=fn zsO%9bp~D2c@?*K9jVN@_vhR03>M_8h!_~%aN!Cnr?s-!;U3SVfmhRwk11A^8Ns`@KeE}+ zN$H}a1U6E;*j5&~Og!xHdfK5M<~xka)x-0N)K_&e7AjMz`toDzasH+^1bZlC!n()crk9kg@$(Y{wdKvbuUd04N^8}t1iOgsKF zGa%%XWx@WoVaNC1!|&{5ZbkopFre-Lu(LCE5HWZBoE#W@er9W<>R=^oYxBvypN#x3 zq#LC8&q)GFP=5^-bpHj?LW=)-g+3_)Ylps!3^YQ{9~O9&K)xgy zMkCWaApU-MI~e^cV{Je75Qr7eF%&_H)BvfyKL=gIA>;OSq(y z052BFz3E(Prg~09>|_Z@!qj}@;8yxnw+#Ej0?Rk<y}4ghbD569B{9hSFr*^ygZ zr6j7P#gtZh6tMk6?4V$*Jgz+#&ug;yOr>=qdI#9U&^am2qoh4Jy}H2%a|#Fs{E(5r z%!ijh;VuGA6)W)cJZx+;9Bp1LMUzN~x_8lQ#D3+sL{be-Jyeo@@dv7XguJ&S5vrH` z>QxOMWn7N-T!D@1(@4>ZlL^y5>m#0!HKovs12GRav4z!>p(1~xok8+_{| z#Ae4{9#NLh#Vj2&JuIn5$d6t@__`o}umFo(n0QxUtd2GKCyE+erwXY?`cm*h&^9*8 zJ+8x6fRZI-e$CRygofIQN^dWysCxgkyr{(_oBwwSRxZora1(%(aC!5BTtj^+YuevI zx?)H#(xlALUp6QJ!=l9N__$cxBZ5p&7;qD3PsXRFVd<({Kh+mShFWJNpy`N@ab7?9 zv5=klvCJ4bx|-pvOO2-+G)6O?$&)ncA#Urze2rlBfp#htudhx-NeRnJ@u%^_bfw4o z4|{b8SkPV3b>Wera1W(+N@p9H>dc6{cnkh-sgr?e%(YkWvK+0YXVwk0=d`)}*47*B z5JGkEdVix!w7-<%r0JF~`ZMMPe;f0EQHuYHxya`puazyph*ZSb1mJAt^k4549BfS; zK7~T&lRb=W{s&t`DJ$B}s-eH1&&-wEOH1KWsKn0a(ZI+G!v&W4A*cl>qAvUv6pbUR z#(f#EKV8~hk&8oayBz4vaswc(?qw1vn`yC zZQDl2PCB-&Uu@g9ZQHhO+v(W0bNig{-k0;;`+wM@#@J)8r?qOYs#&vUna8ILxN7S{ zp1s41KnR8miQJtJtOr|+qk}wrLt+N*z#5o`TmD1)E&QD(Vh&pjZJ_J*0!8dy_ z>^=@v=J)C`x&gjqAYu`}t^S=DFCtc0MkBU2zf|69?xW`Ck~(6zLD)gSE{7n~6w8j_ zoH&~$ED2k5-yRa0!r8fMRy z;QjBYUaUnpd}mf%iVFPR%Dg9!d>g`01m~>2s))`W|5!kc+_&Y>wD@@C9%>-lE`WB0 zOIf%FVD^cj#2hCkFgi-fgzIfOi+ya)MZK@IZhHT5FVEaSbv-oDDs0W)pA0&^nM0TW zmgJmd7b1R7b0a`UwWJYZXp4AJPteYLH>@M|xZFKwm!t3D3&q~av?i)WvAKHE{RqpD{{%OhYkK?47}+}` zrR2(Iv9bhVa;cDzJ%6ntcSbx7v7J@Y4x&+eWSKZ*eR7_=CVIUSB$^lfYe@g+p|LD{ zPSpQmxx@b$%d!05|H}WzBT4_cq?@~dvy<7s&QWtieJ9)hd4)$SZz}#H2UTi$CkFWW|I)v_-NjuH!VypONC=1`A=rm_jfzQ8Fu~1r8i{q-+S_j$ z#u^t&Xnfi5tZtl@^!fUJhx@~Cg0*vXMK}D{>|$#T*+mj(J_@c{jXBF|rm4-8%Z2o! z2z0o(4%8KljCm^>6HDK!{jI7p+RAPcty_~GZ~R_+=+UzZ0qzOwD=;YeZt*?3%UGdr z`c|BPE;yUbnyARUl&XWSNJ<+uRt%!xPF&K;(l$^JcA_CMH6)FZt{>6ah$|(9$2fc~ z=CD00uHM{qv;{Zk9FR0~u|3|Eiqv9?z2#^GqylT5>6JNZwKqKBzzQpKU2_pmtD;CT zi%Ktau!Y2Tldfu&b0UgmF(SSBID)15*r08eoUe#bT_K-G4VecJL2Pa=6D1K6({zj6 za(2Z{r!FY5W^y{qZ}08+h9f>EKd&PN90f}Sc0ejf%kB4+f#T8Q1=Pj=~#pi$U zp#5rMR%W25>k?<$;$x72pkLibu1N|jX4cWjD3q^Pk3js!uK6h7!dlvw24crL|MZs_ zb%Y%?Fyp0bY0HkG^XyS76Ts*|Giw{31LR~+WU5NejqfPr73Rp!xQ1mLgq@mdWncLy z%8}|nzS4P&`^;zAR-&nm5f;D-%yNQPwq4N7&yULM8bkttkD)hVU>h>t47`{8?n2&4 zjEfL}UEagLUYwdx0sB2QXGeRmL?sZ%J!XM`$@ODc2!y|2#7hys=b$LrGbvvjx`Iqi z&RDDm3YBrlKhl`O@%%&rhLWZ*ABFz2nHu7k~3@e4)kO3%$=?GEFUcCF=6-1n!x^vmu+Ai*amgXH+Rknl6U>#9w;A} zn2xanZSDu`4%%x}+~FG{Wbi1jo@wqBc5(5Xl~d0KW(^Iu(U3>WB@-(&vn_PJt9{1`e9Iic@+{VPc`vP776L*viP{wYB2Iff8hB%E3|o zGMOu)tJX!`qJ}ZPzq7>=`*9TmETN7xwU;^AmFZ-ckZjV5B2T09pYliaqGFY|X#E-8 z20b>y?(r-Fn5*WZ-GsK}4WM>@TTqsxvSYWL6>18q8Q`~JO1{vLND2wg@58OaU!EvT z1|o+f1mVXz2EKAbL!Q=QWQKDZpV|jznuJ}@-)1&cdo z^&~b4Mx{*1gurlH;Vhk5g_cM&6LOHS2 zRkLfO#HabR1JD4Vc2t828dCUG#DL}f5QDSBg?o)IYYi@_xVwR2w_ntlpAW0NWk$F1 z$If?*lP&Ka1oWfl!)1c3fl`g*lMW3JOn#)R1+tfwrs`aiFUgz3;XIJ>{QFxLCkK30 zNS-)#DON3yb!7LBHQJ$)4y%TN82DC2-9tOIqzhZ27@WY^<6}vXCWcR5iN{LN8{0u9 zNXayqD=G|e?O^*ms*4P?G%o@J1tN9_76e}E#66mr89%W_&w4n66~R;X_vWD(oArwj z4CpY`)_mH2FvDuxgT+akffhX0b_slJJ*?Jn3O3~moqu2Fs1oL*>7m=oVek2bnprnW zixkaIFU%+3XhNA@@9hyhFwqsH2bM|`P?G>i<-gy>NflhrN{$9?LZ1ynSE_Mj0rADF zhOz4FnK}wpLmQuV zgO4_Oz9GBu_NN>cPLA=`SP^$gxAnj;WjJnBi%Q1zg`*^cG;Q)#3Gv@c^j6L{arv>- zAW%8WrSAVY1sj$=umcAf#ZgC8UGZGoamK}hR7j6}i8#np8ruUlvgQ$j+AQglFsQQq zOjyHf22pxh9+h#n$21&$h?2uq0>C9P?P=Juw0|;oE~c$H{#RGfa>| zj)Iv&uOnaf@foiBJ}_;zyPHcZt1U~nOcNB{)og8Btv+;f@PIT*xz$x!G?u0Di$lo7 zOugtQ$Wx|C($fyJTZE1JvR~i7LP{ zbdIwqYghQAJi9p}V&$=*2Azev$6K@pyblphgpv8^9bN!?V}{BkC!o#bl&AP!3DAjM zmWFsvn2fKWCfjcAQmE+=c3Y7j@#7|{;;0f~PIodmq*;W9Fiak|gil6$w3%b_Pr6K_ zJEG@&!J%DgBZJDCMn^7mk`JV0&l07Bt`1ymM|;a)MOWz*bh2#d{i?SDe9IcHs7 zjCrnyQ*Y5GzIt}>`bD91o#~5H?4_nckAgotN{2%!?wsSl|LVmJht$uhGa+HiH>;av z8c?mcMYM7;mvWr6noUR{)gE!=i7cZUY7e;HXa221KkRoc2UB>s$Y(k%NzTSEr>W(u z<(4mcc)4rB_&bPzX*1?*ra%VF}P1nwiP5cykJ&W{!OTlz&Td0pOkVp+wc z@k=-Hg=()hNg=Q!Ub%`BONH{ z_=ZFgetj@)NvppAK2>8r!KAgi>#%*7;O-o9MOOfQjV-n@BX6;Xw;I`%HBkk20v`qoVd0)}L6_49y1IhR z_OS}+eto}OPVRn*?UHC{eGyFU7JkPz!+gX4P>?h3QOwGS63fv4D1*no^6PveUeE5% zlehjv_3_^j^C({a2&RSoVlOn71D8WwMu9@Nb@=E_>1R*ve3`#TF(NA0?d9IR_tm=P zOP-x;gS*vtyE1Cm zG0L?2nRUFj#aLr-R1fX*$sXhad)~xdA*=hF3zPZhha<2O$Ps+F07w*3#MTe?)T8|A!P!v+a|ot{|^$q(TX`35O{WI0RbU zCj?hgOv=Z)xV?F`@HKI11IKtT^ocP78cqHU!YS@cHI@{fPD?YXL)?sD~9thOAv4JM|K8OlQhPXgnevF=F7GKD2#sZW*d za}ma31wLm81IZxX(W#A9mBvLZr|PoLnP>S4BhpK8{YV_}C|p<)4#yO{#ISbco92^3 zv&kCE(q9Wi;9%7>>PQ!zSkM%qqqLZW7O`VXvcj;WcJ`2~v?ZTYB@$Q&^CTfvy?1r^ z;Cdi+PTtmQwHX_7Kz?r#1>D zS5lWU(Mw_$B&`ZPmqxpIvK<~fbXq?x20k1~9az-Q!uR78mCgRj*eQ>zh3c$W}>^+w^dIr-u{@s30J=)1zF8?Wn|H`GS<=>Om|DjzC{}Jt?{!fSJe*@$H zg>wFnlT)k#T?LslW zu$^7Uy~$SQ21cE?3Ijl+bLfuH^U5P^$@~*UY#|_`uvAIe(+wD2eF}z_y!pvomuVO; zS^9fbdv)pcm-B@CW|Upm<7s|0+$@@<&*>$a{aW+oJ%f+VMO<#wa)7n|JL5egEgoBv zl$BY(NQjE0#*nv=!kMnp&{2Le#30b)Ql2e!VkPLK*+{jv77H7)xG7&=aPHL7LK9ER z5lfHxBI5O{-3S?GU4X6$yVk>lFn;ApnwZybdC-GAvaznGW-lScIls-P?Km2mF>%B2 zkcrXTk+__hj-3f48U%|jX9*|Ps41U_cd>2QW81Lz9}%`mTDIhE)jYI$q$ma7Y-`>% z8=u+Oftgcj%~TU}3nP8&h7k+}$D-CCgS~wtWvM|UU77r^pUw3YCV80Ou*+bH0!mf0 zxzUq4ed6y>oYFz7+l18PGGzhB^pqSt)si=9M>~0(Bx9*5r~W7sa#w+_1TSj3Jn9mW zMuG9BxN=}4645Cpa#SVKjFst;9UUY@O<|wpnZk$kE+to^4!?0@?Cwr3(>!NjYbu?x z1!U-?0_O?k!NdM^-rIQ8p)%?M+2xkhltt*|l=%z2WFJhme7*2xD~@zk#`dQR$6Lmd zb3LOD4fdt$Cq>?1<%&Y^wTWX=eHQ49Xl_lFUA(YQYHGHhd}@!VpYHHm=(1-O=yfK#kKe|2Xc*9}?BDFN zD7FJM-AjVi)T~OG)hpSWqH>vlb41V#^G2B_EvYlWhDB{Z;Q9-0)ja(O+By`31=biA zG&Fs#5!%_mHi|E4Nm$;vVQ!*>=_F;ZC=1DTPB#CICS5fL2T3XmzyHu?bI;m7D4@#; ztr~;dGYwb?m^VebuULtS4lkC_7>KCS)F@)0OdxZIFZp@FM_pHnJes8YOvwB|++#G( z&dm*OP^cz95Wi15vh`Q+yB>R{8zqEhz5of>Po$9LNE{xS<)lg2*roP*sQ}3r3t<}; zPbDl{lk{pox~2(XY5=qg0z!W-x^PJ`VVtz$git7?)!h>`91&&hESZy1KCJ2nS^yMH z!=Q$eTyRi68rKxdDsdt+%J_&lapa{ds^HV9Ngp^YDvtq&-Xp}60B_w@Ma>_1TTC;^ zpbe!#gH}#fFLkNo#|`jcn?5LeUYto%==XBk6Ik0kc4$6Z+L3x^4=M6OI1=z5u#M%0 z0E`kevJEpJjvvN>+g`?gtnbo$@p4VumliZV3Z%CfXXB&wPS^5C+7of2tyVkMwNWBiTE2 z8CdPu3i{*vR-I(NY5syRR}I1TJOV@DJy-Xmvxn^IInF>Tx2e)eE9jVSz69$6T`M9-&om!T+I znia!ZWJRB28o_srWlAxtz4VVft8)cYloIoVF=pL zugnk@vFLXQ_^7;%hn9x;Vq?lzg7%CQR^c#S)Oc-8d=q_!2ZVH764V z!wDKSgP}BrVV6SfCLZnYe-7f;igDs9t+K*rbMAKsp9L$Kh<6Z;e7;xxced zn=FGY<}CUz31a2G}$Q(`_r~75PzM4l_({Hg&b@d8&jC}B?2<+ed`f#qMEWi z`gm!STV9E4sLaQX+sp5Nu9*;9g12naf5?=P9p@H@f}dxYprH+3ju)uDFt^V{G0APn zS;16Dk{*fm6&BCg#2vo?7cbkkI4R`S9SSEJ=#KBk3rl69SxnCnS#{*$!^T9UUmO#&XXKjHKBqLdt^3yVvu8yn|{ zZ#%1CP)8t-PAz(+_g?xyq;C2<9<5Yy<~C74Iw(y>uUL$+$mp(DRcCWbCKiGCZw@?_ zdomfp+C5xt;j5L@VfhF*xvZdXwA5pcdsG>G<8II-|1dhAgzS&KArcb0BD4ZZ#WfiEY{hkCq5%z9@f|!EwTm;UEjKJsUo696V>h zy##eXYX}GUu%t{Gql8vVZKkNhQeQ4C%n|RmxL4ee5$cgwlU+?V7a?(jI#&3wid+Kz5+x^G!bb#$q>QpR#BZ}Xo5UW^ zD&I`;?(a}Oys7-`I^|AkN?{XLZNa{@27Dv^s4pGowuyhHuXc zuctKG2x0{WCvg_sGN^n9myJ}&FXyGmUQnW7fR$=bj$AHR88-q$D!*8MNB{YvTTEyS zn22f@WMdvg5~o_2wkjItJN@?mDZ9UUlat2zCh(zVE=dGi$rjXF7&}*sxac^%HFD`Y zTM5D3u5x**{bW!68DL1A!s&$2XG@ytB~dX-?BF9U@XZABO`a|LM1X3HWCllgl0+uL z04S*PX$%|^WAq%jkzp~%9HyYIF{Ym?k)j3nMwPZ=hlCg9!G+t>tf0o|J2%t1 ztC+`((dUplgm3`+0JN~}&FRRJ3?l*>Y&TfjS>!ShS`*MwO{WIbAZR#<%M|4c4^dY8 z{Rh;-!qhY=dz5JthbWoovLY~jNaw>%tS4gHVlt5epV8ekXm#==Po$)}mh^u*cE>q7*kvX&gq)(AHoItMYH6^s6f(deNw%}1=7O~bTHSj1rm2|Cq+3M z93djjdomWCTCYu!3Slx2bZVy#CWDozNedIHbqa|otsUl+ut?>a;}OqPfQA05Yim_2 zs@^BjPoFHOYNc6VbNaR5QZfSMh2S*`BGwcHMM(1@w{-4jVqE8Eu0Bi%d!E*^Rj?cR z7qgxkINXZR)K^=fh{pc0DCKtrydVbVILI>@Y0!Jm>x-xM!gu%dehm?cC6ok_msDVA*J#{75%4IZt}X|tIVPReZS#aCvuHkZxc zHVMtUhT(wp09+w9j9eRqz~LtuSNi2rQx_QgQ(}jBt7NqyT&ma61ldD(s9x%@q~PQl zp6N*?=N$BtvjQ_xIT{+vhb1>{pM0Arde0!X-y))A4znDrVx8yrP3B1(7bKPE5jR@5 zwpzwT4cu~_qUG#zYMZ_!2Tkl9zP>M%cy>9Y(@&VoB84#%>amTAH{(hL4cDYt!^{8L z645F>BWO6QaFJ-{C-i|-d%j7#&7)$X7pv#%9J6da#9FB5KyDhkA+~)G0^87!^}AP>XaCSScr;kL;Z%RSPD2CgoJ;gpYT5&6NUK$86$T?jRH=w8nI9Z534O?5fk{kd z`(-t$8W|#$3>xoMfXvV^-A(Q~$8SKDE^!T;J+rQXP71XZ(kCCbP%bAQ1|%$%Ov9_a zyC`QP3uPvFoBqr_+$HenHklqyIr>PU_Fk5$2C+0eYy^~7U&(!B&&P2%7#mBUhM!z> z_B$Ko?{Pf6?)gpYs~N*y%-3!1>o-4;@1Zz9VQHh)j5U1aL-Hyu@1d?X;jtDBNk*vMXPn@ z+u@wxHN*{uHR!*g*4Xo&w;5A+=Pf9w#PeZ^x@UD?iQ&${K2c}UQgLRik-rKM#Y5rdDphdcNTF~cCX&9ViRP}`>L)QA4zNXeG)KXFzSDa6 zd^St;inY6J_i=5mcGTx4_^Ys`M3l%Q==f>{8S1LEHn{y(kbxn5g1ezt4CELqy)~TV6{;VW>O9?5^ ztcoxHRa0jQY7>wwHWcxA-BCwzsP>63Kt&3fy*n#Cha687CQurXaRQnf5wc9o8v7Rw zNwGr2fac;Wr-Ldehn7tF^(-gPJwPt@VR1f;AmKgxN&YPL;j=0^xKM{!wuU|^mh3NE zy35quf}MeL!PU;|{OW_x$TBothLylT-J>_x6p}B_jW1L>k)ps6n%7Rh z96mPkJIM0QFNYUM2H}YF5bs%@Chs6#pEnloQhEl?J-)es!(SoJpEPoMTdgA14-#mC zghayD-DJWtUu`TD8?4mR)w5E`^EHbsz2EjH5aQLYRcF{l7_Q5?CEEvzDo(zjh|BKg z3aJl_n#j&eFHsUw4~lxqnr!6NL*se)6H=A+T1e3xUJGQrd}oSPwSy5+$tt{2t5J5@(lFxl43amsARG74iyNC}uuS zd2$=(r6RdamdGx^eatX@F2D8?U23tDpR+Os?0Gq2&^dF+$9wiWf?=mDWfjo4LfRwL zI#SRV9iSz>XCSgEj!cW&9H-njJopYiYuq|2w<5R2!nZ27DyvU4UDrHpoNQZiGPkp@ z1$h4H46Zn~eqdj$pWrv;*t!rTYTfZ1_bdkZmVVIRC21YeU$iS-*XMNK`#p8Z_DJx| zk3Jssf^XP7v0X?MWFO{rACltn$^~q(M9rMYoVxG$15N;nP)A98k^m3CJx8>6}NrUd@wp-E#$Q0uUDQT5GoiK_R{ z<{`g;8s>UFLpbga#DAf%qbfi`WN1J@6IA~R!YBT}qp%V-j!ybkR{uY0X|x)gmzE0J z&)=eHPjBxJvrZSOmt|)hC+kIMI;qgOnuL3mbNR0g^<%|>9x7>{}>a2qYSZAGPt4it?8 zNcLc!Gy0>$jaU?}ZWxK78hbhzE+etM`67*-*x4DN>1_&{@5t7_c*n(qz>&K{Y?10s zXsw2&nQev#SUSd|D8w7ZD2>E<%g^; zV{yE_O}gq?Q|zL|jdqB^zcx7vo(^})QW?QKacx$yR zhG|XH|8$vDZNIfuxr-sYFR{^csEI*IM#_gd;9*C+SysUFejP0{{z7@P?1+&_o6=7V|EJLQun^XEMS)w(=@eMi5&bbH*a0f;iC~2J74V2DZIlLUHD&>mlug5+v z6xBN~8-ovZylyH&gG#ptYsNlT?-tzOh%V#Y33zlsJ{AIju`CjIgf$@gr8}JugRq^c zAVQ3;&uGaVlVw}SUSWnTkH_6DISN&k2QLMBe9YU=sA+WiX@z)FoSYX`^k@B!j;ZeC zf&**P?HQG6Rk98hZ*ozn6iS-dG}V>jQhb3?4NJB*2F?6N7Nd;EOOo;xR7acylLaLy z9)^lykX39d@8@I~iEVar4jmjjLWhR0d=EB@%I;FZM$rykBNN~jf>#WbH4U{MqhhF6 zU??@fSO~4EbU4MaeQ_UXQcFyO*Rae|VAPLYMJEU`Q_Q_%s2*>$#S^)&7er+&`9L=1 z4q4ao07Z2Vsa%(nP!kJ590YmvrWg+YrgXYs_lv&B5EcoD`%uL79WyYA$0>>qi6ov7 z%`ia~J^_l{p39EY zv>>b}Qs8vxsu&WcXEt8B#FD%L%ZpcVtY!rqVTHe;$p9rbb5O{^rFMB>auLn-^;s+-&P1#h~mf~YLg$8M9 zZ4#87;e-Y6x6QO<{McUzhy(%*6| z)`D~A(TJ$>+0H+mct(jfgL4x%^oC^T#u(bL)`E2tBI#V1kSikAWmOOYrO~#-cc_8! zCe|@1&mN2{*ceeiBldHCdrURk4>V}79_*TVP3aCyV*5n@jiNbOm+~EQ_}1#->_tI@ zqXv+jj2#8xJtW508rzFrYcJxoek@iW6SR@1%a%Bux&;>25%`j3UI`0DaUr7l79`B1 zqqUARhW1^h6=)6?;@v>xrZNM;t}{yY3P@|L}ey@gG( z9r{}WoYN(9TW&dE2dEJIXkyHA4&pU6ki=rx&l2{DLGbVmg4%3Dlfvn!GB>EVaY_%3+Df{fBiqJV>~Xf8A0aqUjgpa} zoF8YXO&^_x*Ej}nw-$-F@(ddB>%RWoPUj?p8U{t0=n>gAI83y<9Ce@Q#3&(soJ{64 z37@Vij1}5fmzAuIUnXX`EYe;!H-yTVTmhAy;y8VZeB#vD{vw9~P#DiFiKQ|kWwGFZ z=jK;JX*A;Jr{#x?n8XUOLS;C%f|zj-7vXtlf_DtP7bpurBeX%Hjwr z4lI-2TdFpzkjgiv!8Vfv`=SP+s=^i3+N~1ELNWUbH|ytVu>EyPN_3(4TM^QE1swRo zoV7Y_g)a>28+hZG0e7g%@2^s>pzR4^fzR-El}ARTmtu!zjZLuX%>#OoU3}|rFjJg} zQ2TmaygxJ#sbHVyiA5KE+yH0LREWr%^C*yR|@gM$nK2P zo}M}PV0v))uJh&33N>#aU376@ZH79u(Yw`EQ2hM3SJs9f99+cO6_pNW$j$L-CtAfe zYfM)ccwD!P%LiBk!eCD?fHCGvgMQ%Q2oT_gmf?OY=A>&PaZQOq4eT=lwbaf}33LCH zFD|)lu{K7$8n9gX#w4~URjZxWm@wlH%oL#G|I~Fb-v^0L0TWu+`B+ZG!yII)w05DU z>GO?n(TN+B=>HdxVDSlIH76pta$_LhbBg;eZ`M7OGcqt||qi zogS72W1IN%=)5JCyOHWoFP7pOFK0L*OAh=i%&VW&4^LF@R;+K)t^S!96?}^+5QBIs zjJNTCh)?)4k^H^g1&jc>gysM`y^8Rm3qsvkr$9AeWwYpa$b22=yAd1t<*{ zaowSEFP+{y?Ob}8&cwfqoy4Pb9IA~VnM3u!trIK$&&0Op#Ql4j>(EW?UNUv#*iH1$ z^j>+W{afcd`{e&`-A{g}{JnIzYib)!T56IT@YEs{4|`sMpW3c8@UCoIJv`XsAw!XC z34|Il$LpW}CIHFC5e*)}00I5{%OL*WZRGzC0?_}-9{#ue?-ug^ zLE|uv-~6xnSs_2_&CN9{9vyc!Xgtn36_g^wI0C4s0s^;8+p?|mm;Odt3`2ZjwtK;l zfd6j)*Fr#53>C6Y8(N5?$H0ma;BCF3HCjUs7rpb2Kf*x3Xcj#O8mvs#&33i+McX zQpBxD8!O{5Y8D&0*QjD=Yhl9%M0)&_vk}bmN_Ud^BPN;H=U^bn&(csl-pkA+GyY0Z zKV7sU_4n;}uR78ouo8O%g*V;79KY?3d>k6%gpcmQsKk&@Vkw9yna_3asGt`0Hmj59 z%0yiF*`jXhByBI9QsD=+>big5{)BGe&+U2gAARGe3ID)xrid~QN_{I>k}@tzL!Md_ z&=7>TWciblF@EMC3t4-WX{?!m!G6$M$1S?NzF*2KHMP3Go4=#ZHkeIv{eEd;s-yD# z_jU^Ba06TZqvV|Yd;Z_sN%$X=!T+&?#p+OQIHS%!LO`Hx0q_Y0MyGYFNoM{W;&@0@ zLM^!X4KhdtsET5G<0+|q0oqVXMW~-7LW9Bg}=E$YtNh1#1D^6Mz(V9?2g~I1( zoz9Cz=8Hw98zVLwC2AQvp@pBeKyidn6Xu0-1SY1((^Hu*-!HxFUPs)yJ+i`^BC>PC zjwd0mygOVK#d2pRC9LxqGc6;Ui>f{YW9Bvb>33bp^NcnZoH~w9(lM5@JiIlfa-6|k ziy31UoMN%fvQfhi8^T+=yrP{QEyb-jK~>$A4SZT-N56NYEbpvO&yUme&pWKs3^94D zH{oXnUTb3T@H+RgzML*lejx`WAyw*?K7B-I(VJx($2!NXYm%3`=F~TbLv3H<{>D?A zJo-FDYdSA-(Y%;4KUP2SpHKAIcv9-ld(UEJE7=TKp|Gryn;72?0LHqAN^fk6%8PCW z{g_-t)G5uCIf0I`*F0ZNl)Z>))MaLMpXgqWgj-y;R+@A+AzDjsTqw2Mo9ULKA3c70 z!7SOkMtZb+MStH>9MnvNV0G;pwSW9HgP+`tg}e{ij0H6Zt5zJ7iw`hEnvye!XbA@!~#%vIkzowCOvq5I5@$3wtc*w2R$7!$*?}vg4;eDyJ_1=ixJuEp3pUS27W?qq(P^8$_lU!mRChT}ctvZz4p!X^ zOSp|JOAi~f?UkwH#9k{0smZ7-#=lK6X3OFEMl7%)WIcHb=#ZN$L=aD`#DZKOG4p4r zwlQ~XDZ`R-RbF&hZZhu3(67kggsM-F4Y_tI^PH8PMJRcs7NS9ogF+?bZB*fcpJ z=LTM4W=N9yepVvTj&Hu~0?*vR1HgtEvf8w%Q;U0^`2@e8{SwgX5d(cQ|1(!|i$km! zvY03MK}j`sff;*-%mN~ST>xU$6Bu?*Hm%l@0dk;j@%>}jsgDcQ)Hn*UfuThz9(ww_ zasV`rSrp_^bp-0sx>i35FzJwA!d6cZ5#5#nr@GcPEjNnFHIrtUYm1^Z$;{d&{hQV9 z6EfFHaIS}46p^5I-D_EcwwzUUuO}mqRh&T7r9sfw`)G^Q%oHxEs~+XoM?8e*{-&!7 z7$m$lg9t9KP9282eke608^Q2E%H-xm|oJ8=*SyEo} z@&;TQ3K)jgspgKHyGiKVMCz>xmC=H5Fy3!=TP)-R3|&1S-B)!6q50wfLHKM@7Bq6E z44CY%G;GY>tC`~yh!qv~YdXw! zSkquvYNs6k1r7>Eza?Vkkxo6XRS$W7EzL&A`o>=$HXgBp{L(i^$}t`NcnAxzbH8Ht z2!;`bhKIh`f1hIFcI5bHI=ueKdzmB9)!z$s-BT4ItyY|NaA_+o=jO%MU5as9 zc2)aLP>N%u>wlaXTK!p)r?+~)L+0eCGb5{8WIk7K52$nufnQ+m8YF+GQc&{^(zh-$ z#wyWV*Zh@d!b(WwXqvfhQX)^aoHTBkc;4ossV3&Ut*k>AI|m+{#kh4B!`3*<)EJVj zwrxK>99v^k4&Y&`Awm>|exo}NvewV%E+@vOc>5>%H#BK9uaE2$vje zWYM5fKuOTtn96B_2~~!xJPIcXF>E_;yO8AwpJ4)V`Hht#wbO3Ung~@c%%=FX4)q+9 z99#>VC2!4l`~0WHs9FI$Nz+abUq# zz`Of97})Su=^rGp2S$)7N3rQCj#0%2YO<R&p>$<#lgXcUj=4H_{oAYiT3 z44*xDn-$wEzRw7#@6aD)EGO$0{!C5Z^7#yl1o;k0PhN=aVUQu~eTQ^Xy{z8Ow6tk83 z4{5xe%(hx)%nD&|e*6sTWH`4W&U!Jae#U4TnICheJmsw{l|CH?UA{a6?2GNgpZLyzU2UlFu1ZVwlALmh_DOs03J^Cjh1im`E3?9&zvNmg(MuMw&0^Lu$(#CJ*q6DjlKsY-RMJ^8yIY|{SQZ*9~CH|u9L z`R78^r=EbbR*_>5?-)I+$6i}G)%mN(`!X72KaV(MNUP7Nv3MS9S|Pe!%N2AeOt5zG zVJ;jI4HZ$W->Ai_4X+`9c(~m=@ek*m`ZQbv3ryI-AD#AH=`x$~WeW~M{Js57(K7(v ze5`};LG|%C_tmd>bkufMWmAo&B+DT9ZV~h(4jg0>^aeAqL`PEUzJJtI8W1M!bQWpv zvN(d}E1@nlYa!L!!A*RN!(Q3F%J?5PvQ0udu?q-T)j3JKV~NL>KRb~w-lWc685uS6 z=S#aR&B8Sc8>cGJ!!--?kwsJTUUm`Jk?7`H z7PrO~xgBrSW2_tTlCq1LH8*!o?pj?qxy8}(=r_;G18POrFh#;buWR0qU24+XUaVZ0 z?(sXcr@-YqvkCmHr{U2oPogHL{r#3r49TeR<{SJX1pcUqyWPrkYz^X8#QW~?F)R5i z>p^!i<;qM8Nf{-fd6!_&V*e_9qP6q(s<--&1Ttj01j0w>bXY7y1W*%Auu&p|XSOH=)V7Bd4fUKh&T1)@cvqhuD-d=?w}O zjI%i(f|thk0Go*!d7D%0^ztBfE*V=(ZIN84f5HU}T9?ulmEYzT5usi=DeuI*d|;M~ zp_=Cx^!4k#=m_qSPBr5EK~E?3J{dWWPH&oCcNepYVqL?nh4D5ynfWip$m*YlZ8r^Z zuFEUL-nW!3qjRCLIWPT0x)FDL7>Yt7@8dA?R2kF@WE>ysMY+)lTsgNM#3VbXVGL}F z1O(>q>2a+_`6r5Xv$NZAnp=Kgnr3)cL(^=8ypEeOf3q8(HGe@7Tt59;yFl||w|mnO zHDxg2G3z8=(6wjj9kbcEY@Z0iOd7Gq5GiPS5% z*sF1J<#daxDV2Z8H>wxOF<;yKzMeTaSOp_|XkS9Sfn6Mpe9UBi1cSTieGG5$O;ZLIIJ60Y>SN4vC?=yE_CWlo(EEE$e4j?z&^FM%kNmRtlbEL^dPPgvs9sbK5fGw*r@ z+!EU@u$T8!nZh?Fdf_qk$VuHk^yVw`h`_#KoS*N%epIIOfQUy_&V}VWDGp3tplMbf z5Se1sJUC$7N0F1-9jdV2mmGK{-}fu|Nv;12jDy0<-kf^AmkDnu6j~TPWOgy1MT68|D z=4=50jVbUKdKaQgD`eWGr3I&^<6uhkjz$YwItY8%Yp9{z4-{6g{73<_b*@XJ4Nm3-3z z?BW3{aY_ccRjb@W1)i5nLg|7BnWS!B`_Uo9CWaE`Ij327QH?i)9A}4Ug4wmxVVa^b z-4+m%-wwOl7cKH7+=x&nrCrbEC)Q$fpg&V83#uEH;C=GNMz`ps@^RxK%T*8%OPnC` z{WO~J%nxYJ`x|N%?&i7?;{_8t^jM&=50HlaOQj8fS}_`moH$c;vI<|cruPFnpT8yU zS%rPOCUSd5Zdb(zwk`hqwTQn)*&n)uYsP*F_(~xEWq}C= zv30kFmZFwJZ@ELVX3?$dXQh|icO7UrL*_5G=I^xXjImz`ZPp>?g#tf(ej~KaIU0algsG!IS09;>?MvqGg#c{i+}qY|{P8W~O%#>|gFd z<1dr$-oxyRGN17yZo1OwLnzwYs0|;IS_nymNB0IlSzPQ%-r`?T=;_XQ^~&#}b|AB} zkNbN5uB?-sUB-T5QLlg%Uk3)uHB;>VIzGe9_J9 zaeISkQm!v(9d(0ML^b9fR^sfHFlH?7Mvddt37OuR{|O0{uv)(&-6<87W4 zyO>s!=cPgP3O&7xxU5DlIPw_o3O>6o6Qb?JWs3qw#p3sBc3g$?Dx zi(6D+DYgV;GrUis-CL%Qe{nvZnwaVXmbhH(|GFh|Q)k=1uvA$I@1DXI7bKlQ@8D6P zS?(*?><>)G49q0wr;NajpxP4W2G)kHl6^=Z>hrNEI4Mwd_$O6$1dXF;Q#hE(-eeW6 zz03GJF%Wl?HO=_ztv5*zRlcU~{+{k%#N59mgm~eK>P!QZ6E?#Cu^2)+K8m@ySvZ*5 z|HDT}BkF@3!l(0%75G=1u2hETXEj!^1Z$!)!lyGXlWD!_vqGE$Z)#cUVBqlORW>0^ zDjyVTxwKHKG|0}j-`;!R-p>}qQfBl(?($7pP<+Y8QE#M8SCDq~k<+>Q^Zf@cT_WdX3~BSe z+|KK|7OL5Hm5(NFP~j>Ct3*$wi0n0!xl=(C61`q&cec@mFlH(sy%+RH<=s)8aAPN`SfJdkAQjdv82G5iRdv8 zh{9wHUZaniSEpslXl^_ODh}mypC?b*9FzLjb~H@3DFSe;D(A-K3t3eOTB(m~I6C;(-lKAvit(70k`%@+O*Ztdz;}|_TS~B?Tpmi=QKC^m_ z2YpEaT3iiz*;T~ap1yiA)a`dKMwu`^UhIUeltNQ1Yjo=q@bI@&3zH?rVUg=IxLy-ni zyxDu%-Fr{H6owTjZU2O5>nDb=q&Jz_TjeSq%!2m40x&U6w~GQ({quPL73IsJS;f`$ zsuhioqCBj(gJ>2hoo)Gou7(WP*pX)f=Y=!=k!&1K?EYY%jJ~X&DnK{^saPQK<1BJ z_A`_{%ZozcB(3w$z^To^6d|XuT@=X~wtW!+{4ID@N{AB~J6AL5vuY>JwvWCNFKsKh zd}@>q@_WV#QZ&UJ0#?X(pXR!oyXOEG3rqzHbCzGLONDb042i$})fM@XF)uSP(DHUc z^&{|$*xe{cs?Gp8=B%RY3L7#$ve$?TWh>MZdxF1zH1v}1z+$Ov#G7?%D)bBCyDe*% zSeKSpETC2V1){II>@UwJi>4uBN+iAx+82E~gb|Cr&8E^i&)A!uv-g?jzH99wU}8+# z$nh>yvb;TwZmS@7LrvuCu_d0-WxFNI&C7%sWuTL%YU!l|I1{|->=dlOeHOCtUO#zkS3ESO8LHV4hTdQL5EdV zuWD33fFPH}HPrW^s$Qn1Xgp&AT6<-He{{4%eIu3rN=iK|9mURdKXfB&Q?qGok%!cs ze53UP{Z!TO-Y@q2;;k2avA3`lm4OoN4@S*k=UA)7H;qZ`d8`XaYFCv?Ba+uGW@r5v z&&{nf(24WSBOhc7!qF^@0cz;XcUynNaj6w2349;s!K{KVqs5yS{ z7VubS`2OzT^5#1~6Tt^RTvt9-J|D2F>y~>2;jeF>g`hx5l%B3H=aLExQihuYngzlnBTYOTHJQMzl>kwqN5JYs)Ej zblA@ntkUS~xi+}y6|(81helS}Q~&VB37qyV|S3Y=><^1wh%msQM?fz z<58MX(=|PSUKCF#)dbhR%D&xgCD?$aR0qen+wpp6 zst}vX18!Be96TD??j1HsHTUx(a&@F?=gT`Q$oJFFyrh^;zgz!(NlAHGn0cJy@us=w zNhC#l5G;H}+>49Nsh12=ZPO2r*2OBQe5kpb&1?*PIBFitK8}FUfb~S-#hKfF0o#&d z#3aPkB$9scYku&kA6{0xHnBV#&Wei5J>5T-XX-gUXEPo+9b7WL=*XESc(3BshL`aj zXp}QIp*40}oWJt*l043e8_5;H5PI5c)U&IEw5dF(4zjX0y_lk9 zAp@!mK>WUqHo)-jop=DoK>&no>kAD=^qIE7qis&_*4~ z6q^EF$D@R~3_xseCG>Ikb6Gfofb$g|75PPyyZN&tiRxqovo_k zO|HA|sgy#B<32gyU9x^&)H$1jvw@qp+1b(eGAb)O%O!&pyX@^nQd^9BQ4{(F8<}|A zhF&)xusQhtoXOOhic=8#Xtt5&slLia3c*a?dIeczyTbC#>FTfiLST57nc3@Y#v_Eg#VUv zT8cKH#f3=1PNj!Oroz_MAR*pow%Y0*6YCYmUy^7`^r|j23Q~^*TW#cU7CHf0eAD_0 zEWEVddxFgQ7=!nEBQ|ibaScslvhuUk^*%b#QUNrEB{3PG@uTxNwW}Bs4$nS9wc(~O zG7Iq>aMsYkcr!9#A;HNsJrwTDYkK8ikdj{M;N$sN6BqJ<8~z>T20{J8Z2rRUuH7~3 z=tgS`AgxbBOMg87UT4Lwge`*Y=01Dvk>)^{Iu+n6fuVX4%}>?3czOGR$0 zpp*wp>bsFFSV`V;r_m+TZns$ZprIi`OUMhe^cLE$2O+pP3nP!YB$ry}2THx2QJs3< za1;>d-AggCarrQ>&Z!d@;mW+!q6eXhb&`GbzUDSxpl8AJ#Cm#tuc)_xh(2NV=5XMs zrf_ozRYO$NkC=pKFX5OH8v1>0i9Z$ec`~Mf+_jQ68spn(CJwclDhEEkH2Qw;${J$clv__nUjn5jA0wCLEnu1j;v!0vB>Ri6m9`;R{JMS%^)4FC zU0Z44+u$I$w=Bj|iu4DT5h~sS`C*zbmX?@-crY}E+hy>}2~C0Nn(EKk@5^qO4@l@! z6O0lr%tzGC`D^)8xU3FnMZVm0kX1sBWhaQyzVoXFWwr%Ny?=2M{5s#5i7fTu3gEkG zc{(Pr$v=;`Y#&`y*J}#M9ux>0?xu!`$9cUKm#Bdd_&S#LPTS?ZPV6zN6>W6JTS~-LfjL{mB=b(KMk3 z2HjBSlJeyUVqDd=Mt!=hpYsvby2GL&3~zm;0{^nZJq+4vb?5HH4wufvr}IX42sHeK zm@x?HN$8TsTavXs)tLDFJtY9b)y~Tl@7z4^I8oUQq4JckH@~CVQ;FoK(+e0XAM>1O z(ei}h?)JQp>)d=6ng-BZF1Z5hsAKW@mXq+hU?r8I(*%`tnIIOXw7V6ZK(T9RFJJe@ zZS!aC+p)Gf2Ujc=a6hx4!A1Th%YH!Lb^xpI!Eu` zmJO{9rw){B1Ql18d%F%da+Tbu1()?o(zT7StYqK6_w`e+fjXq5L^y(0 z09QA6H4oFj59c2wR~{~>jUoDzDdKz}5#onYPJRwa`SUO)Pd4)?(ENBaFVLJr6Kvz= zhTtXqbx09C1z~~iZt;g^9_2nCZ{};-b4dQJbv8HsWHXPVg^@(*!@xycp#R?a|L!+` zY5w))JWV`Gls(=}shH0#r*;~>_+-P5Qc978+QUd>J%`fyn{*TsiG-dWMiJXNgwBaT zJ=wgYFt+1ACW)XwtNx)Q9tA2LPoB&DkL16P)ERWQlY4%Y`-5aM9mZ{eKPUgI!~J3Z zkMd5A_p&v?V-o-6TUa8BndiX?ooviev(DKw=*bBVOW|=zps9=Yl|-R5@yJe*BPzN}a0mUsLn{4LfjB_oxpv(mwq# zSY*%E{iB)sNvWfzg-B!R!|+x(Q|b@>{-~cFvdDHA{F2sFGA5QGiIWy#3?P2JIpPKg6ncI^)dvqe`_|N=8 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..15638b8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,45 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WorkTrack" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +include(":app") + +include(":core:common") +include(":core:model") +include(":core:domain") +include(":core:database") +include(":core:datastore") +include(":core:network") +include(":core:data") +include(":core:sync") +include(":core:designsystem") + +include(":feature:auth") +include(":feature:dashboard") +include(":feature:attendance") +include(":feature:leave") +include(":feature:payslips") +include(":feature:profile") From 8083efe4b2f9c755a0d7a1f19246304e938cb6f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:49:04 +0000 Subject: [PATCH 003/139] feat(core): domain, data, sync, and design system modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core:common — AppResult/AppError taxonomy, ULID generator, injectable time and dispatchers, haversine geo math (with unit tests) - core:model — full domain model (org, employees, attendance, shifts, leave, payroll, announcements, sync state) - core:domain — repository contracts and use cases: sign-in, punch with geofence evaluation (accuracy-credited), leave application with balance checks and half-day math, dashboard aggregation (unit tested) - core:database — Room schema v1: 15 tables incl. append-only punches, outbox queue, and per-resource sync cursors - core:datastore — persisted session (DataStore), no token storage - core:network — Retrofit/kotlinx-serialization API client, RFC 7807 error mapping, token refresh interceptor, connectivity monitor - core:data — repository implementations with offline-first outbox writes and the push/pull sync engine (server-authoritative) - core:sync — WorkManager scheduling: periodic + expedited unique work - core:designsystem — Material 3 theme and shared components Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- core/common/build.gradle.kts | 8 + .../common/coroutines/DispatcherProvider.kt | 24 ++ .../worktrack/core/common/geo/GeoDistance.kt | 22 ++ .../app/worktrack/core/common/id/Ulid.kt | 48 +++ .../worktrack/core/common/result/AppError.kt | 46 +++ .../core/common/result/AppErrorMessages.kt | 16 + .../worktrack/core/common/result/AppResult.kt | 46 +++ .../core/common/time/TimeProvider.kt | 21 ++ .../app/worktrack/core/common/id/UlidTest.kt | 35 ++ core/data/build.gradle.kts | 27 ++ .../data/auth/FirebaseAuthTokenProvider.kt | 22 ++ .../app/worktrack/core/data/di/DataModule.kt | 48 +++ .../worktrack/core/data/mapper/DtoMappers.kt | 242 ++++++++++++ .../core/data/mapper/EntityMappers.kt | 215 +++++++++++ .../repository/AnnouncementRepositoryImpl.kt | 39 ++ .../repository/AttendanceRepositoryImpl.kt | 183 +++++++++ .../data/repository/AuthRepositoryImpl.kt | 91 +++++ .../data/repository/LeaveRepositoryImpl.kt | 179 +++++++++ .../data/repository/PayslipRepositoryImpl.kt | 48 +++ .../data/repository/SyncRepositoryImpl.kt | 351 ++++++++++++++++++ .../worktrack/core/data/sync/OutboxWriter.kt | 41 ++ .../worktrack/core/data/sync/ResourceTypes.kt | 42 +++ core/database/build.gradle.kts | 15 + .../core/database/WorkTrackDatabase.kt | 62 ++++ .../core/database/converter/Converters.kt | 34 ++ .../core/database/dao/AnnouncementDao.kt | 28 ++ .../core/database/dao/AttendanceDao.kt | 77 ++++ .../worktrack/core/database/dao/LeaveDao.kt | 76 ++++ .../app/worktrack/core/database/dao/OrgDao.kt | 40 ++ .../worktrack/core/database/dao/OutboxDao.kt | 46 +++ .../worktrack/core/database/dao/PayslipDao.kt | 49 +++ .../worktrack/core/database/dao/ShiftDao.kt | 38 ++ .../core/database/dao/SyncCursorDao.kt | 19 + .../core/database/di/DatabaseModule.kt | 33 ++ .../database/entity/AttendanceEntities.kt | 86 +++++ .../core/database/entity/LeaveEntities.kt | 68 ++++ .../core/database/entity/OrgEntities.kt | 59 +++ .../core/database/entity/PayrollEntities.kt | 60 +++ .../core/database/entity/PlatformEntities.kt | 51 +++ core/datastore/build.gradle.kts | 17 + .../worktrack/core/datastore/SessionStore.kt | 91 +++++ .../core/datastore/di/DataStoreModule.kt | 26 ++ core/designsystem/build.gradle.kts | 11 + .../core/designsystem/component/Buttons.kt | 63 ++++ .../designsystem/component/Scaffolding.kt | 56 +++ .../core/designsystem/component/States.kt | 86 +++++ .../core/designsystem/component/StatusChip.kt | 63 ++++ .../core/designsystem/component/TextFields.kt | 42 +++ .../core/designsystem/theme/Color.kt | 45 +++ .../core/designsystem/theme/Theme.kt | 88 +++++ .../worktrack/core/designsystem/theme/Type.kt | 81 ++++ core/domain/build.gradle.kts | 11 + .../repository/AnnouncementRepository.kt | 13 + .../domain/repository/AttendanceRepository.kt | 32 ++ .../core/domain/repository/AuthRepository.kt | 25 ++ .../core/domain/repository/LeaveRepository.kt | 35 ++ .../domain/repository/PayslipRepository.kt | 14 + .../core/domain/repository/SyncRepository.kt | 31 ++ .../attendance/EvaluateGeofenceUseCase.kt | 55 +++ .../ObserveAttendanceHistoryUseCase.kt | 16 + .../ObserveTodayAttendanceUseCase.kt | 12 + .../usecase/attendance/PunchClockUseCase.kt | 73 ++++ .../usecase/auth/ObserveSessionUseCase.kt | 12 + .../core/domain/usecase/auth/SignInUseCase.kt | 40 ++ .../domain/usecase/auth/SignOutUseCase.kt | 10 + .../dashboard/ObserveDashboardUseCase.kt | 51 +++ .../domain/usecase/leave/ApplyLeaveUseCase.kt | 82 ++++ .../leave/CancelLeaveRequestUseCase.kt | 12 + .../leave/DecideLeaveRequestUseCase.kt | 25 ++ .../leave/ObserveLeaveOverviewUseCase.kt | 33 ++ .../leave/ObservePendingApprovalsUseCase.kt | 12 + .../payslip/ObservePayslipDetailUseCase.kt | 13 + .../usecase/payslip/ObservePayslipsUseCase.kt | 13 + .../usecase/sync/ObserveSyncStateUseCase.kt | 12 + .../domain/usecase/sync/TriggerSyncUseCase.kt | 10 + .../attendance/EvaluateGeofenceUseCaseTest.kt | 101 +++++ .../usecase/leave/ApplyLeaveUseCaseTest.kt | 60 +++ core/model/build.gradle.kts | 3 + .../app/worktrack/core/model/Announcement.kt | 17 + .../app/worktrack/core/model/Attendance.kt | 72 ++++ .../app/worktrack/core/model/Employee.kt | 73 ++++ .../kotlin/app/worktrack/core/model/Leave.kt | 75 ++++ .../kotlin/app/worktrack/core/model/Org.kt | 52 +++ .../app/worktrack/core/model/Payroll.kt | 35 ++ .../kotlin/app/worktrack/core/model/Shift.kt | 33 ++ .../kotlin/app/worktrack/core/model/Sync.kt | 15 + core/network/build.gradle.kts | 21 ++ .../app/worktrack/core/network/ApiCall.kt | 60 +++ .../worktrack/core/network/NetworkMonitor.kt | 61 +++ .../worktrack/core/network/WorkTrackApi.kt | 71 ++++ .../core/network/auth/AuthTokenProvider.kt | 14 + .../core/network/di/NetworkModule.kt | 81 ++++ .../core/network/dto/AttendanceDtos.kt | 58 +++ .../worktrack/core/network/dto/Envelope.kt | 27 ++ .../worktrack/core/network/dto/LeaveDtos.kt | 73 ++++ .../app/worktrack/core/network/dto/OrgDtos.kt | 82 ++++ .../worktrack/core/network/dto/PayrollDtos.kt | 35 ++ .../core/network/dto/PlatformDtos.kt | 59 +++ .../worktrack/core/network/dto/SessionDtos.kt | 16 + .../network/interceptor/AuthInterceptor.kt | 46 +++ .../network/serializer/JavaTimeSerializers.kt | 34 ++ core/sync/build.gradle.kts | 18 + .../app/worktrack/core/sync/SyncWorker.kt | 41 ++ .../core/sync/WorkManagerSyncScheduler.kt | 64 ++++ .../app/worktrack/core/sync/di/SyncModule.kt | 16 + 105 files changed, 5359 insertions(+) create mode 100644 core/common/build.gradle.kts create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt create mode 100644 core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt create mode 100644 core/data/build.gradle.kts create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt create mode 100644 core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt create mode 100644 core/database/build.gradle.kts create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt create mode 100644 core/datastore/build.gradle.kts create mode 100644 core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt create mode 100644 core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt create mode 100644 core/designsystem/build.gradle.kts create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt create mode 100644 core/domain/build.gradle.kts create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt create mode 100644 core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt create mode 100644 core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt create mode 100644 core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt create mode 100644 core/model/build.gradle.kts create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Org.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt create mode 100644 core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt create mode 100644 core/network/build.gradle.kts create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt create mode 100644 core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt create mode 100644 core/sync/build.gradle.kts create mode 100644 core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt create mode 100644 core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt create mode 100644 core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..cd553d3 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} + +dependencies { + api(libs.kotlinx.coroutines.core) + implementation(libs.javax.inject) +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt b/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt new file mode 100644 index 0000000..d4aa8a8 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.common.coroutines + +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** Injectable dispatchers so coroutine context is swappable in tests. */ +interface DispatcherProvider { + val io: CoroutineDispatcher + val default: CoroutineDispatcher + val main: CoroutineDispatcher +} + +class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider { + override val io: CoroutineDispatcher = Dispatchers.IO + override val default: CoroutineDispatcher = Dispatchers.Default + override val main: CoroutineDispatcher = Dispatchers.Main +} + +/** Application-lifetime CoroutineScope (SupervisorJob + Default), provided by the app module. */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt b/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt new file mode 100644 index 0000000..cb81dcd --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt @@ -0,0 +1,22 @@ +package app.worktrack.core.common.geo + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** Great-circle distance (haversine). Accurate to well under geofence tolerances. */ +object GeoDistance { + + private const val EARTH_RADIUS_METERS = 6_371_000.0 + + fun meters(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLng = Math.toRadians(lng2 - lng1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLng / 2) * sin(dLng / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return EARTH_RADIUS_METERS * c + } +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt b/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt new file mode 100644 index 0000000..3323b2d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.common.id + +import java.security.SecureRandom + +/** + * ULID generator (26-char Crockford base32: 48-bit timestamp + 80-bit randomness). + * + * ULIDs are the platform-wide ID scheme because they are generatable offline + * (no server round-trip), lexicographically sortable by creation time (index + * friendly in both Room and Firestore), and collision-safe across devices. + */ +object Ulid { + + private const val ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + private const val TIME_CHARS = 10 + private const val RANDOM_BYTES = 10 // 80 bits -> 16 base32 chars + + private val random = SecureRandom() + + fun generate(timestampMillis: Long = System.currentTimeMillis()): String { + require(timestampMillis >= 0) { "timestamp must be non-negative" } + val chars = CharArray(26) + + var ts = timestampMillis + for (i in TIME_CHARS - 1 downTo 0) { + chars[i] = ENCODING[(ts and 0x1F).toInt()] + ts = ts ushr 5 + } + + val rnd = ByteArray(RANDOM_BYTES) + random.nextBytes(rnd) + var buffer = 0L + var bitsInBuffer = 0 + var out = TIME_CHARS + for (b in rnd) { + buffer = (buffer shl 8) or (b.toLong() and 0xFF) + bitsInBuffer += 8 + while (bitsInBuffer >= 5) { + bitsInBuffer -= 5 + chars[out++] = ENCODING[((buffer ushr bitsInBuffer) and 0x1F).toInt()] + } + } + return String(chars) + } + + fun isValid(value: String): Boolean = + value.length == 26 && value.all { ENCODING.indexOf(it.uppercaseChar()) >= 0 } +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt new file mode 100644 index 0000000..7dbfac0 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.common.result + +/** + * Canonical error taxonomy for the whole app. Layers map their native failures + * (IOException, HTTP problem+json, Firestore errors) into one of these so that + * UI and domain logic never depend on transport-specific exception types. + */ +sealed interface AppError { + + /** No connectivity, DNS failure, timeout — safe to retry when back online. */ + data object Network : AppError + + /** Missing/expired credentials; the session must be re-established. */ + data object Unauthenticated : AppError + + /** Authenticated but not allowed (RBAC denial, tenant mismatch). */ + data object PermissionDenied : AppError + + data object NotFound : AppError + + /** Client-side or server-side input validation failure. */ + data class Validation( + val message: String, + val fieldErrors: Map = emptyMap(), + ) : AppError + + /** + * A domain rule rejected the operation (e.g. GEOFENCE_VIOLATION, + * INSUFFICIENT_LEAVE_BALANCE). [code] matches the API error catalog. + */ + data class Business(val code: String, val message: String) : AppError + + /** Non-2xx HTTP response that does not map to a more specific error. */ + data class Http(val status: Int, val code: String? = null, val message: String? = null) : AppError + + /** Programming errors and anything unforeseen; always logged, never swallowed. */ + data class Unexpected(val cause: Throwable? = null) : AppError +} + +/** True when retrying the same operation later can plausibly succeed. */ +val AppError.isRetryable: Boolean + get() = when (this) { + AppError.Network -> true + is AppError.Http -> status in 500..599 || status == 429 + else -> false + } diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt new file mode 100644 index 0000000..ffea1df --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.common.result + +/** + * Default English user-facing message per error. Feature UIs may override for + * screen-specific phrasing; localization replaces this in the l10n pass (P1). + */ +fun AppError.userMessage(): String = when (this) { + AppError.Network -> "You're offline. Changes are saved and will sync automatically." + AppError.Unauthenticated -> "Your session has expired. Please sign in again." + AppError.PermissionDenied -> "You don't have permission to do that." + AppError.NotFound -> "That item could not be found." + is AppError.Validation -> message + is AppError.Business -> message + is AppError.Http -> message ?: "Something went wrong on the server ($status)." + is AppError.Unexpected -> "Something went wrong. Please try again." +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt new file mode 100644 index 0000000..85a0b3d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.common.result + +/** + * Explicit success/failure channel for every fallible operation. + * Exceptions never cross layer boundaries; they are converted at the edge. + */ +sealed interface AppResult { + data class Success(val data: T) : AppResult + data class Failure(val error: AppError) : AppResult + + companion object { + fun success(data: T): AppResult = Success(data) + fun failure(error: AppError): AppResult = Failure(error) + } +} + +inline fun AppResult.map(transform: (T) -> R): AppResult = when (this) { + is AppResult.Success -> AppResult.Success(transform(data)) + is AppResult.Failure -> this +} + +inline fun AppResult.flatMap(transform: (T) -> AppResult): AppResult = when (this) { + is AppResult.Success -> transform(data) + is AppResult.Failure -> this +} + +inline fun AppResult.onSuccess(action: (T) -> Unit): AppResult { + if (this is AppResult.Success) action(data) + return this +} + +inline fun AppResult.onFailure(action: (AppError) -> Unit): AppResult { + if (this is AppResult.Failure) action(error) + return this +} + +inline fun AppResult.fold(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) { + is AppResult.Success -> onSuccess(data) + is AppResult.Failure -> onFailure(error) +} + +fun AppResult.getOrNull(): T? = (this as? AppResult.Success)?.data + +fun AppResult.errorOrNull(): AppError? = (this as? AppResult.Failure)?.error + +val AppResult<*>.isSuccess: Boolean get() = this is AppResult.Success diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt b/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt new file mode 100644 index 0000000..29a5e0d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt @@ -0,0 +1,21 @@ +package app.worktrack.core.common.time + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** + * Injectable clock. Production code never calls Instant.now() directly so that + * time-dependent logic (attendance windows, accruals) is deterministic in tests. + */ +interface TimeProvider { + fun now(): Instant + fun zone(): ZoneId + fun today(): LocalDate = LocalDate.ofInstant(now(), zone()) +} + +class SystemTimeProvider @Inject constructor() : TimeProvider { + override fun now(): Instant = Instant.now() + override fun zone(): ZoneId = ZoneId.systemDefault() +} diff --git a/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt b/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt new file mode 100644 index 0000000..7054791 --- /dev/null +++ b/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.common.id + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UlidTest { + + @Test + fun `generates 26 char crockford base32`() { + val ulid = Ulid.generate() + assertEquals(26, ulid.length) + assertTrue(Ulid.isValid(ulid)) + } + + @Test + fun `is lexicographically sortable by timestamp`() { + val earlier = Ulid.generate(timestampMillis = 1_000_000L) + val later = Ulid.generate(timestampMillis = 2_000_000L) + assertTrue(earlier < later) + } + + @Test + fun `encodes identical timestamps with identical prefix`() { + val a = Ulid.generate(timestampMillis = 1_700_000_000_000) + val b = Ulid.generate(timestampMillis = 1_700_000_000_000) + assertEquals(a.take(10), b.take(10)) + } + + @Test + fun `no collisions across a large batch`() { + val batch = (1..10_000).map { Ulid.generate() }.toSet() + assertEquals(10_000, batch.size) + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..288dd96 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.data" +} + +dependencies { + api(projects.core.domain) + implementation(projects.core.common) + implementation(projects.core.model) + implementation(projects.core.database) + implementation(projects.core.datastore) + implementation(projects.core.network) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.kotlinx.serialization.json) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + + testImplementation(libs.turbine) +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt new file mode 100644 index 0000000..ab51117 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt @@ -0,0 +1,22 @@ +package app.worktrack.core.data.auth + +import app.worktrack.core.network.auth.AuthTokenProvider +import com.google.firebase.auth.FirebaseAuth +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.tasks.await + +@Singleton +class FirebaseAuthTokenProvider @Inject constructor( + private val firebaseAuth: FirebaseAuth, +) : AuthTokenProvider { + + override suspend fun idToken(forceRefresh: Boolean): String? = + try { + firebaseAuth.currentUser?.getIdToken(forceRefresh)?.await()?.token + } catch (_: Exception) { + // Offline or revoked: callers treat null as "no credential"; the API + // responds 401 and the UI routes to re-authentication if needed. + null + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt b/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt new file mode 100644 index 0000000..8f3dbf2 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.data.di + +import app.worktrack.core.common.coroutines.DefaultDispatcherProvider +import app.worktrack.core.common.coroutines.DispatcherProvider +import app.worktrack.core.common.time.SystemTimeProvider +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.auth.FirebaseAuthTokenProvider +import app.worktrack.core.data.repository.AnnouncementRepositoryImpl +import app.worktrack.core.data.repository.AttendanceRepositoryImpl +import app.worktrack.core.data.repository.AuthRepositoryImpl +import app.worktrack.core.data.repository.LeaveRepositoryImpl +import app.worktrack.core.data.repository.PayslipRepositoryImpl +import app.worktrack.core.data.repository.SyncRepositoryImpl +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.network.auth.AuthTokenProvider +import com.google.firebase.auth.FirebaseAuth +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + + @Binds fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + @Binds fun bindAttendanceRepository(impl: AttendanceRepositoryImpl): AttendanceRepository + @Binds fun bindLeaveRepository(impl: LeaveRepositoryImpl): LeaveRepository + @Binds fun bindPayslipRepository(impl: PayslipRepositoryImpl): PayslipRepository + @Binds fun bindAnnouncementRepository(impl: AnnouncementRepositoryImpl): AnnouncementRepository + @Binds fun bindSyncRepository(impl: SyncRepositoryImpl): SyncRepository + @Binds fun bindAuthTokenProvider(impl: FirebaseAuthTokenProvider): AuthTokenProvider + @Binds fun bindTimeProvider(impl: SystemTimeProvider): TimeProvider + @Binds fun bindDispatcherProvider(impl: DefaultDispatcherProvider): DispatcherProvider + + companion object { + @Provides + @Singleton + fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance() + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt new file mode 100644 index 0000000..542379b --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt @@ -0,0 +1,242 @@ +package app.worktrack.core.data.mapper + +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.RoleCode +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.model.UserSession +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.BranchDto +import app.worktrack.core.network.dto.EmployeeDto +import app.worktrack.core.network.dto.GeofenceDto +import app.worktrack.core.network.dto.LeaveBalanceDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.LeaveTypeDto +import app.worktrack.core.network.dto.MeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.PunchDto +import app.worktrack.core.network.dto.ShiftAssignmentDto +import app.worktrack.core.network.dto.ShiftDto +import java.time.LocalTime + +/** Server DTO -> Room entity. Server rows always land as SYNCED. */ + +private inline fun > String.toEnumOr(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + +fun MeDto.toSession() = UserSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.mapNotNull(RoleCode::fromCode).toSet(), + branchIds = branchIds, + companyName = companyName, +) + +fun BranchDto.toEntity() = BranchEntity( + id = id, + companyId = companyId, + name = name, + code = code, + address = address, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + timezone = timezone, + updatedAt = updatedAt, +) + +fun GeofenceDto.toEntity() = GeofenceEntity( + id = id, + companyId = companyId, + branchId = branchId, + name = name, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + active = active, + updatedAt = updatedAt, +) + +fun EmployeeDto.toEntity() = EmployeeEntity( + id = id, + companyId = companyId, + employeeCode = employeeCode, + firstName = firstName, + lastName = lastName, + email = email, + phone = phone, + avatarUrl = avatarUrl, + branchId = branchId, + departmentId = departmentId, + positionId = positionId, + managerId = managerId, + employmentType = employmentType, + joinDateEpochDay = joinDate.toEpochDay(), + status = status, + updatedAt = updatedAt, +) + +fun ShiftDto.toEntity() = ShiftEntity( + id = id, + companyId = companyId, + name = name, + code = code, + startTimeSecondOfDay = LocalTime.parse(startTime).toSecondOfDay(), + endTimeSecondOfDay = LocalTime.parse(endTime).toSecondOfDay(), + breakMinutes = breakMinutes, + graceInMinutes = graceInMinutes, + graceOutMinutes = graceOutMinutes, + isNightShift = isNightShift, + active = active, + updatedAt = updatedAt, +) + +fun ShiftAssignmentDto.toEntity() = ShiftAssignmentEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + shiftId = shiftId, + date = date, + branchId = branchId, + source = source, + updatedAt = updatedAt, +) + +fun PunchDto.toEntity() = AttendancePunchEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + punchedAt = punchedAt, + type = type.toEnumOr(PunchType.IN), + method = method.toEnumOr(PunchMethod.MANUAL), + latitude = latitude, + longitude = longitude, + accuracyMeters = accuracyMeters, + geofenceId = geofenceId, + insideFence = insideFence, + note = note, + serverValidated = serverValidated, + invalidReason = invalidReason, + syncStatus = SyncStatus.SYNCED, +) + +fun AttendanceDayDto.toEntity() = AttendanceDayEntity( + id = id, + employeeId = employeeId, + date = date, + shiftId = shiftId, + firstInAt = firstInAt, + lastOutAt = lastOutAt, + workedMinutes = workedMinutes, + lateMinutes = lateMinutes, + earlyOutMinutes = earlyOutMinutes, + overtimeMinutes = overtimeMinutes, + status = status, +) + +fun LeaveTypeDto.toEntity() = LeaveTypeEntity( + id = id, + companyId = companyId, + name = name, + code = code, + colorHex = colorHex, + isPaid = isPaid, + requiresAttachment = requiresAttachment, + active = active, + updatedAt = updatedAt, +) + +fun LeaveBalanceDto.toEntity() = LeaveBalanceEntity( + id = id, + employeeId = employeeId, + leaveTypeId = leaveTypeId, + periodYear = periodYear, + entitledDays = entitledDays, + accruedDays = accruedDays, + usedDays = usedDays, + carriedOverDays = carriedOverDays, + pendingDays = pendingDays, + updatedAt = updatedAt, +) + +fun LeaveRequestDto.toEntity(syncStatus: SyncStatus = SyncStatus.SYNCED) = LeaveRequestEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + employeeName = employeeName, + leaveTypeId = leaveTypeId, + startDate = startDate, + endDate = endDate, + startHalfDay = startHalfDay, + endHalfDay = endHalfDay, + days = days, + reason = reason, + status = status.toEnumOr(LeaveStatus.PENDING), + currentApproverId = currentApproverId, + decidedAt = decidedAt, + decisionNote = decisionNote, + createdAt = createdAt, + updatedAt = updatedAt, + syncStatus = syncStatus, +) + +fun PayslipDto.toEntity() = PayslipEntity( + id = id, + companyId = companyId, + runId = runId, + employeeId = employeeId, + periodYear = periodYear, + periodMonth = periodMonth, + currency = currency, + gross = gross, + totalDeductions = totalDeductions, + net = net, + workedDays = workedDays, + paidLeaveDays = paidLeaveDays, + lopDays = lopDays, + overtimeMinutes = overtimeMinutes, + status = status, + pdfUrl = pdfUrl, + updatedAt = updatedAt, +) + +fun PayslipDto.toLineEntities(): List = lines.map { + PayslipLineEntity( + payslipId = id, + componentCode = it.componentCode, + componentName = it.componentName, + type = it.type, + amount = it.amount, + ) +} + +fun AnnouncementDto.toEntity() = AnnouncementEntity( + id = id, + companyId = companyId, + title = title, + body = body, + priority = priority, + publishedAt = publishedAt, + expiresAt = expiresAt, + createdByName = createdByName, + updatedAt = updatedAt, +) diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt new file mode 100644 index 0000000..de1e1ae --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt @@ -0,0 +1,215 @@ +package app.worktrack.core.data.mapper + +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.PayslipWithLines +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.AnnouncementPriority +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendanceDayStatus +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Branch +import app.worktrack.core.model.Employee +import app.worktrack.core.model.EmployeeStatus +import app.worktrack.core.model.EmploymentType +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.PayComponentType +import app.worktrack.core.model.Payslip +import app.worktrack.core.model.PayslipLine +import app.worktrack.core.model.PayslipStatus +import app.worktrack.core.model.Shift +import java.time.LocalDate +import java.time.LocalTime + +/** Room entity -> domain model. Unknown enum names degrade to safe defaults. */ + +private inline fun > String.toEnumOr(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + +fun BranchEntity.toModel() = Branch( + id = id, + companyId = companyId, + name = name, + code = code, + address = address, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + timezone = timezone, + updatedAt = updatedAt, +) + +fun GeofenceEntity.toModel() = Geofence( + id = id, + companyId = companyId, + branchId = branchId, + name = name, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + active = active, + updatedAt = updatedAt, +) + +fun EmployeeEntity.toModel() = Employee( + id = id, + companyId = companyId, + employeeCode = employeeCode, + firstName = firstName, + lastName = lastName, + email = email, + phone = phone, + avatarUrl = avatarUrl, + branchId = branchId, + departmentId = departmentId, + positionId = positionId, + managerId = managerId, + employmentType = employmentType.toEnumOr(EmploymentType.FULL_TIME), + joinDate = LocalDate.ofEpochDay(joinDateEpochDay), + status = status.toEnumOr(EmployeeStatus.ACTIVE), + updatedAt = updatedAt, +) + +fun AttendancePunchEntity.toModel() = AttendancePunch( + id = id, + companyId = companyId, + employeeId = employeeId, + punchedAt = punchedAt, + type = type, + method = method, + latitude = latitude, + longitude = longitude, + accuracyMeters = accuracyMeters, + geofenceId = geofenceId, + insideFence = insideFence, + note = note, + serverValidated = serverValidated, + invalidReason = invalidReason, + syncStatus = syncStatus, +) + +fun AttendanceDayEntity.toModel() = AttendanceDay( + id = id, + employeeId = employeeId, + date = date, + shiftId = shiftId, + firstInAt = firstInAt, + lastOutAt = lastOutAt, + workedMinutes = workedMinutes, + lateMinutes = lateMinutes, + earlyOutMinutes = earlyOutMinutes, + overtimeMinutes = overtimeMinutes, + status = status.toEnumOr(AttendanceDayStatus.PENDING), +) + +fun ShiftEntity.toModel() = Shift( + id = id, + companyId = companyId, + name = name, + code = code, + startTime = LocalTime.ofSecondOfDay(startTimeSecondOfDay.toLong()), + endTime = LocalTime.ofSecondOfDay(endTimeSecondOfDay.toLong()), + breakMinutes = breakMinutes, + graceInMinutes = graceInMinutes, + graceOutMinutes = graceOutMinutes, + isNightShift = isNightShift, + active = active, + updatedAt = updatedAt, +) + +fun LeaveTypeEntity.toModel() = LeaveType( + id = id, + companyId = companyId, + name = name, + code = code, + colorHex = colorHex, + isPaid = isPaid, + requiresAttachment = requiresAttachment, + active = active, + updatedAt = updatedAt, +) + +fun LeaveBalanceEntity.toModel() = LeaveBalance( + id = id, + employeeId = employeeId, + leaveTypeId = leaveTypeId, + periodYear = periodYear, + entitledDays = entitledDays, + accruedDays = accruedDays, + usedDays = usedDays, + carriedOverDays = carriedOverDays, + pendingDays = pendingDays, + updatedAt = updatedAt, +) + +fun LeaveRequestEntity.toModel() = LeaveRequest( + id = id, + companyId = companyId, + employeeId = employeeId, + employeeName = employeeName, + leaveTypeId = leaveTypeId, + startDate = startDate, + endDate = endDate, + startHalfDay = startHalfDay, + endHalfDay = endHalfDay, + days = days, + reason = reason, + status = status, + currentApproverId = currentApproverId, + decidedAt = decidedAt, + decisionNote = decisionNote, + createdAt = createdAt, + updatedAt = updatedAt, + syncStatus = syncStatus, +) + +fun PayslipWithLines.toModel() = Payslip( + id = payslip.id, + companyId = payslip.companyId, + runId = payslip.runId, + employeeId = payslip.employeeId, + periodYear = payslip.periodYear, + periodMonth = payslip.periodMonth, + currency = payslip.currency, + gross = payslip.gross, + totalDeductions = payslip.totalDeductions, + net = payslip.net, + workedDays = payslip.workedDays, + paidLeaveDays = payslip.paidLeaveDays, + lopDays = payslip.lopDays, + overtimeMinutes = payslip.overtimeMinutes, + status = payslip.status.toEnumOr(PayslipStatus.FINALIZED), + pdfUrl = payslip.pdfUrl, + lines = lines.map { + PayslipLine( + componentCode = it.componentCode, + componentName = it.componentName, + type = it.type.toEnumOr(PayComponentType.EARNING), + amount = it.amount, + ) + }, + updatedAt = payslip.updatedAt, +) + +fun AnnouncementEntity.toModel() = Announcement( + id = id, + companyId = companyId, + title = title, + body = body, + priority = priority.toEnumOr(AnnouncementPriority.NORMAL), + publishedAt = publishedAt, + expiresAt = expiresAt, + createdByName = createdByName, + updatedAt = updatedAt, +) diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt new file mode 100644 index 0000000..4e137e7 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt @@ -0,0 +1,39 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.model.Announcement +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +@Singleton +class AnnouncementRepositoryImpl @Inject constructor( + private val announcementDao: AnnouncementDao, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, +) : AnnouncementRepository { + + override fun observeAnnouncements(): Flow> = + // 'now' is captured per collection so returning to the screen re-filters + // expired announcements without needing a DB write. + flow { + emitAll(announcementDao.observeActive(timeProvider.now())) + }.map { items -> items.map { it.toModel() } } + + override suspend fun refresh(): AppResult = + apiCall { api.announcements() } + .map { envelope -> + announcementDao.upsertAnnouncements(envelope.data.map { it.toEntity() }) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt new file mode 100644 index 0000000..d528304 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt @@ -0,0 +1,183 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.data.sync.OutboxOpTypes +import app.worktrack.core.data.sync.OutboxWriter +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.model.TodayAttendance +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.PunchCreateDto +import java.time.Duration +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +@Singleton +class AttendanceRepositoryImpl @Inject constructor( + private val attendanceDao: AttendanceDao, + private val shiftDao: ShiftDao, + private val orgDao: OrgDao, + private val sessionStore: SessionStore, + private val outboxWriter: OutboxWriter, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : AttendanceRepository { + + override fun observeToday(): Flow = + sessionStore.session.flatMapLatest { session -> + val today = timeProvider.today() + if (session == null) { + flowOf(emptyToday(today)) + } else { + val zone = timeProvider.zone() + val dayStart = today.atStartOfDay(zone).toInstant() + val dayEnd = today.plusDays(1).atStartOfDay(zone).toInstant() + combine( + attendanceDao.observePunchesBetween(session.employeeId, dayStart, dayEnd), + shiftDao.observeShiftForDate(session.employeeId, today), + ) { punches, shift -> + val ordered = punches.sortedBy { it.punchedAt } + TodayAttendance( + date = today, + clockedIn = ordered.lastOrNull()?.type == PunchType.IN, + firstInAt = ordered.firstOrNull { it.type == PunchType.IN }?.punchedAt, + lastPunchAt = ordered.lastOrNull()?.punchedAt, + punchCount = ordered.size, + workedMinutesSoFar = closedPairMinutes(ordered), + shift = shift?.toModel(), + ) + } + } + } + + override fun observeDays(from: LocalDate, to: LocalDate): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + attendanceDao.observeDaysBetween(session.employeeId, from, to) + .map { days -> days.map { it.toModel() } } + } + } + + override fun observePunches(from: LocalDate, to: LocalDate): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + val zone = timeProvider.zone() + attendanceDao.observePunchesBetween( + employeeId = session.employeeId, + from = from.atStartOfDay(zone).toInstant(), + to = to.plusDays(1).atStartOfDay(zone).toInstant(), + ).map { punches -> punches.map { it.toModel() } } + } + } + + override fun observeActiveGeofences(): Flow> = + orgDao.observeActiveGeofences().map { fences -> fences.map { it.toModel() } } + + override suspend fun punch(command: PunchCommand): AppResult { + val session = sessionStore.session.first() + ?: return AppResult.failure(AppError.Unauthenticated) + + val entity = AttendancePunchEntity( + id = Ulid.generate(timeProvider.now().toEpochMilli()), + companyId = session.companyId, + employeeId = session.employeeId, + punchedAt = timeProvider.now(), + type = command.type, + method = command.method, + latitude = command.latitude, + longitude = command.longitude, + accuracyMeters = command.accuracyMeters, + geofenceId = command.geofenceId, + insideFence = command.insideFence, + note = command.note, + serverValidated = false, + invalidReason = null, + syncStatus = SyncStatus.PENDING, + ) + attendanceDao.insertPunch(entity) + + val payload = PunchCreateDto( + id = entity.id, + punchedAt = entity.punchedAt, + type = entity.type.name, + method = entity.method.name, + latitude = entity.latitude, + longitude = entity.longitude, + accuracyMeters = entity.accuracyMeters, + geofenceId = entity.geofenceId, + insideFence = entity.insideFence, + kioskToken = command.kioskToken, + note = entity.note, + ) + outboxWriter.enqueue( + opType = OutboxOpTypes.CREATE, + resourceType = ResourceTypes.PUNCHES, + resourceId = entity.id, + payloadJson = json.encodeToString(PunchCreateDto.serializer(), payload), + ) + return AppResult.success(entity.toModel()) + } + + override suspend fun refresh(from: LocalDate, to: LocalDate): AppResult = + apiCall { api.attendanceDays(from.toString(), to.toString()) } + .map { envelope -> + attendanceDao.upsertDays(envelope.data.map { it.toEntity() }) + } + + private fun emptyToday(today: LocalDate) = TodayAttendance( + date = today, + clockedIn = false, + firstInAt = null, + lastPunchAt = null, + punchCount = 0, + workedMinutesSoFar = 0, + shift = null, + ) + + /** Sums completed IN→OUT intervals; an open IN is counted live by the UI clock. */ + private fun closedPairMinutes(ordered: List): Int { + var total = 0L + var openIn: AttendancePunchEntity? = null + for (punch in ordered) { + when (punch.type) { + PunchType.IN -> if (openIn == null) openIn = punch + PunchType.OUT -> openIn?.let { + total += Duration.between(it.punchedAt, punch.punchedAt).toMinutes() + openIn = null + } + } + } + return total.toInt() + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..ff07899 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,91 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.coroutines.DispatcherProvider +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.result.onFailure +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.data.mapper.toSession +import app.worktrack.core.database.WorkTrackDatabase +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.model.UserSession +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext + +@Singleton +class AuthRepositoryImpl @Inject constructor( + private val firebaseAuth: FirebaseAuth, + private val api: WorkTrackApi, + private val sessionStore: SessionStore, + private val database: WorkTrackDatabase, + private val dispatchers: DispatcherProvider, +) : AuthRepository { + + override val session: Flow = sessionStore.session + + override suspend fun signIn(email: String, password: String): AppResult { + try { + firebaseAuth.signInWithEmailAndPassword(email, password).await() + } catch (e: CancellationException) { + throw e + } catch (e: FirebaseAuthInvalidUserException) { + return AppResult.failure(invalidCredentials()) + } catch (e: FirebaseAuthInvalidCredentialsException) { + return AppResult.failure(invalidCredentials()) + } catch (e: FirebaseNetworkException) { + return AppResult.failure(AppError.Network) + } catch (e: Exception) { + return AppResult.failure(AppError.Unexpected(e)) + } + + // Resolve tenant context. A Firebase account without a provisioned + // employee (no /me) must not end up half signed in. + return apiCall { api.me() } + .map { it.data.toSession() } + .onSuccess { sessionStore.save(it) } + .onFailure { firebaseAuth.signOut() } + } + + override suspend fun refreshSession(): AppResult = + apiCall { api.me() } + .map { it.data.toSession() } + .onSuccess { sessionStore.save(it) } + + override suspend fun sendPasswordReset(email: String): AppResult = try { + firebaseAuth.sendPasswordResetEmail(email.trim()).await() + AppResult.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: FirebaseNetworkException) { + AppResult.failure(AppError.Network) + } catch (e: Exception) { + // Do not reveal whether the account exists (user enumeration). + AppResult.success(Unit) + } + + override suspend fun signOut() { + firebaseAuth.signOut() + sessionStore.clear() + withContext(dispatchers.io) { + // Tenant data never survives a sign-out on shared devices. + database.clearAllTables() + } + } + + private fun invalidCredentials() = AppError.Business( + code = "INVALID_CREDENTIALS", + message = "Email or password is incorrect", + ) +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt new file mode 100644 index 0000000..207b984 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt @@ -0,0 +1,179 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.data.sync.OutboxOpTypes +import app.worktrack.core.data.sync.OutboxWriter +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.usecase.leave.ApplyLeaveUseCase +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.LeaveDecisionDto +import app.worktrack.core.network.dto.LeaveRequestCreateDto +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +@Singleton +class LeaveRepositoryImpl @Inject constructor( + private val leaveDao: LeaveDao, + private val sessionStore: SessionStore, + private val outboxWriter: OutboxWriter, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : LeaveRepository { + + override fun observeTypes(): Flow> = + leaveDao.observeActiveTypes().map { types -> types.map { it.toModel() } } + + override fun observeMyBalances(periodYear: Int): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + leaveDao.observeBalances(session.employeeId, periodYear) + .map { balances -> balances.map { it.toModel() } } + } + } + + override fun observeMyRequests(): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + leaveDao.observeMyRequests(session.employeeId) + .map { requests -> requests.map { it.toModel() } } + } + } + + override fun observePendingApprovals(): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null || !session.isApprover) { + flowOf(emptyList()) + } else { + leaveDao.observePendingApprovals(session.employeeId) + .map { requests -> requests.map { it.toModel() } } + } + } + + override suspend fun apply(application: LeaveApplication): AppResult { + val session = sessionStore.session.first() + ?: return AppResult.failure(AppError.Unauthenticated) + + val now = timeProvider.now() + val entity = LeaveRequestEntity( + id = Ulid.generate(now.toEpochMilli()), + companyId = session.companyId, + employeeId = session.employeeId, + employeeName = session.displayName, + leaveTypeId = application.leaveTypeId, + startDate = application.startDate, + endDate = application.endDate, + startHalfDay = application.startHalfDay, + endHalfDay = application.endHalfDay, + days = ApplyLeaveUseCase.calculateDays(application), + reason = application.reason.trim(), + status = LeaveStatus.PENDING, + currentApproverId = null, // resolved server-side from the approval chain + decidedAt = null, + decisionNote = null, + createdAt = now, + updatedAt = now, + syncStatus = SyncStatus.PENDING, + ) + leaveDao.upsertRequests(listOf(entity)) + + val payload = LeaveRequestCreateDto( + id = entity.id, + leaveTypeId = entity.leaveTypeId, + startDate = entity.startDate, + endDate = entity.endDate, + startHalfDay = entity.startHalfDay, + endHalfDay = entity.endHalfDay, + reason = entity.reason, + ) + outboxWriter.enqueue( + opType = OutboxOpTypes.CREATE, + resourceType = ResourceTypes.LEAVE_REQUESTS, + resourceId = entity.id, + payloadJson = json.encodeToString(LeaveRequestCreateDto.serializer(), payload), + ) + return AppResult.success(entity.toModel()) + } + + override suspend fun cancel(requestId: String): AppResult { + val existing = leaveDao.requestById(requestId) + ?: return AppResult.failure(AppError.NotFound) + if (existing.syncStatus != SyncStatus.SYNCED) { + return AppResult.failure( + AppError.Business( + code = "NOT_SYNCED", + message = "Wait for this request to finish syncing before cancelling", + ), + ) + } + return apiCall { api.cancelLeaveRequest(requestId, idempotencyKey = Ulid.generate()) } + .map { envelope -> + leaveDao.upsertRequests(listOf(envelope.data.toEntity())) + } + } + + override suspend fun decide( + requestId: String, + decision: ApprovalDecision, + note: String?, + ): AppResult = + apiCall { + api.decideLeaveRequest( + requestId = requestId, + body = LeaveDecisionDto(decision = decision.name, note = note), + idempotencyKey = Ulid.generate(), + ) + }.map { envelope -> + leaveDao.upsertRequests(listOf(envelope.data.toEntity())) + } + + override suspend fun refresh(): AppResult { + val mine = apiCall { api.leaveRequests(scope = "mine") } + val approvals = apiCall { api.leaveRequests(scope = "approvals") } + + listOf(mine, approvals).forEach { result -> + if (result is AppResult.Success) { + result.data.data.forEach { dto -> + // Never clobber a locally created request that hasn't pushed yet. + val local = leaveDao.requestById(dto.id) + if (local == null || local.syncStatus != SyncStatus.PENDING) { + leaveDao.upsertRequests(listOf(dto.toEntity())) + } + } + } + } + return when { + mine is AppResult.Failure -> mine + approvals is AppResult.Failure -> approvals + else -> AppResult.success(Unit) + } + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt new file mode 100644 index 0000000..cb3bfb2 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toLineEntities +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +@Singleton +class PayslipRepositoryImpl @Inject constructor( + private val payslipDao: PayslipDao, + private val sessionStore: SessionStore, + private val api: WorkTrackApi, +) : PayslipRepository { + + override fun observePayslips(periodYear: Int): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + payslipDao.observePayslips(session.employeeId, periodYear) + .map { slips -> slips.map { it.toModel() } } + } + } + + override fun observePayslip(payslipId: String): Flow = + payslipDao.observePayslip(payslipId).map { it?.toModel() } + + override suspend fun refresh(periodYear: Int): AppResult = + apiCall { api.payslips(periodYear) } + .map { envelope -> + envelope.data.forEach { dto -> + payslipDao.replacePayslip(dto.toEntity(), dto.toLineEntities()) + } + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt new file mode 100644 index 0000000..12d0621 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt @@ -0,0 +1,351 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toLineEntities +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.dao.SyncCursorDao +import app.worktrack.core.database.entity.OutboxEntryEntity +import app.worktrack.core.database.entity.SyncCursorEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.BranchDto +import app.worktrack.core.network.dto.EmployeeDto +import app.worktrack.core.network.dto.GeofenceDto +import app.worktrack.core.network.dto.LeaveBalanceDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.LeaveTypeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.PunchDto +import app.worktrack.core.network.dto.ShiftAssignmentDto +import app.worktrack.core.network.dto.ShiftDto +import app.worktrack.core.network.dto.SyncOpDto +import app.worktrack.core.network.dto.SyncOpResultDto +import app.worktrack.core.network.dto.SyncPushRequestDto +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +/** + * The client sync engine. + * + * Push: drains the outbox in FIFO batches through POST /sync/push. Transport + * failures requeue the batch untouched; per-op rejections are terminal and are + * reflected onto the owning row (never silently dropped). + * + * Pull: per-resource-type delta cursors through GET /sync/pull, reference data + * first. Cursors only advance after a page is fully applied, so a crash + * mid-page replays idempotently (all appliers are upserts / insert-ignore). + */ +@Singleton +class SyncRepositoryImpl @Inject constructor( + private val outboxDao: OutboxDao, + private val syncCursorDao: SyncCursorDao, + private val orgDao: OrgDao, + private val shiftDao: ShiftDao, + private val attendanceDao: AttendanceDao, + private val leaveDao: LeaveDao, + private val payslipDao: PayslipDao, + private val announcementDao: AnnouncementDao, + private val sessionStore: SessionStore, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : SyncRepository { + + private data class EngineState( + val isSyncing: Boolean = false, + val lastSuccessAt: Instant? = null, + val lastError: String? = null, + ) + + private val mutex = Mutex() + private val engineState = MutableStateFlow(EngineState()) + + override fun observeSyncState(): Flow = combine( + engineState, + outboxDao.observePendingCount(), + outboxDao.observeFailedCount(), + ) { state, pending, failed -> + SyncState( + isSyncing = state.isSyncing, + pendingOperations = pending, + failedOperations = failed, + lastSuccessAt = state.lastSuccessAt, + lastError = state.lastError, + ) + } + + override suspend fun syncNow(): AppResult = mutex.withLock { + if (sessionStore.session.first() == null) { + return AppResult.success(Unit) // signed out: nothing to sync + } + engineState.update { it.copy(isSyncing = true) } + + val result = runSyncCycle() + + engineState.update { + when (result) { + is AppResult.Success -> EngineState( + isSyncing = false, + lastSuccessAt = timeProvider.now(), + lastError = null, + ) + + is AppResult.Failure -> it.copy( + isSyncing = false, + lastError = result.error.toShortMessage(), + ) + } + } + result + } + + private suspend fun runSyncCycle(): AppResult { + pushOutbox().let { if (it is AppResult.Failure) return it } + pullDeltas().let { if (it is AppResult.Failure) return it } + attendanceDao.prunePunchesBefore(timeProvider.now().minus(PUNCH_RETENTION)) + return AppResult.success(Unit) + } + + // ------------------------------------------------------------------ push + + private suspend fun pushOutbox(): AppResult { + outboxDao.requeueInFlight() // recover from a previous process death + + while (true) { + val batch = outboxDao.nextPending(PUSH_BATCH_SIZE) + if (batch.isEmpty()) return AppResult.success(Unit) + outboxDao.markInFlight(batch.map { it.id }) + + val ops = batch.map { entry -> + SyncOpDto( + opId = entry.id, + opType = entry.opType, + resourceType = entry.resourceType, + resourceId = entry.resourceId, + idempotencyKey = entry.idempotencyKey, + payload = json.parseToJsonElement(entry.payloadJson).jsonObject, + ) + } + + when (val response = apiCall { api.syncPush(SyncPushRequestDto(ops)) }) { + is AppResult.Failure -> { + // Transport-level failure: nothing was durably rejected. + outboxDao.requeueInFlight() + return response + } + + is AppResult.Success -> { + val byId = batch.associateBy { it.id } + response.data.data.results.forEach { opResult -> + byId[opResult.opId]?.let { applyOpResult(it, opResult) } + } + } + } + } + } + + private suspend fun applyOpResult(entry: OutboxEntryEntity, result: SyncOpResultDto) { + when (result.status) { + "APPLIED" -> { + applyServerEcho(entry, result.resource) + outboxDao.delete(entry.id) + } + + else -> { + // Business rejection: terminal for this op. Keep the entry as + // FAILED for observability and mark the owning row. + applyRejection(entry, result) + outboxDao.markAttemptFailed( + id = entry.id, + state = "FAILED", + error = result.errorCode ?: result.message, + ) + } + } + } + + private suspend fun applyServerEcho(entry: OutboxEntryEntity, resource: JsonObject?) { + when (entry.resourceType) { + ResourceTypes.PUNCHES -> { + val dto = resource?.let { json.decodeFromJsonElement(PunchDto.serializer(), it) } + attendanceDao.updatePunchSyncResult( + id = entry.resourceId, + syncStatus = SyncStatus.SYNCED, + serverValidated = dto?.serverValidated ?: true, + invalidReason = dto?.invalidReason, + ) + } + + ResourceTypes.LEAVE_REQUESTS -> { + resource + ?.let { json.decodeFromJsonElement(LeaveRequestDto.serializer(), it) } + ?.let { leaveDao.upsertRequests(listOf(it.toEntity())) } + } + } + } + + private suspend fun applyRejection(entry: OutboxEntryEntity, result: SyncOpResultDto) { + val reason = result.errorCode ?: result.message ?: "REJECTED" + when (entry.resourceType) { + ResourceTypes.PUNCHES -> attendanceDao.updatePunchSyncResult( + id = entry.resourceId, + syncStatus = SyncStatus.FAILED, + serverValidated = false, + invalidReason = reason, + ) + + ResourceTypes.LEAVE_REQUESTS -> { + leaveDao.requestById(entry.resourceId)?.let { existing -> + leaveDao.updateRequestStatus( + id = existing.id, + status = existing.status, + syncStatus = SyncStatus.FAILED, + updatedAt = timeProvider.now(), + ) + } + } + } + } + + // ------------------------------------------------------------------ pull + + private suspend fun pullDeltas(): AppResult { + for (resourceType in ResourceTypes.pullOrder) { + var cursor = syncCursorDao.cursor(resourceType)?.cursor + while (true) { + val page = when (val res = apiCall { api.syncPull(resourceType, cursor) }) { + is AppResult.Failure -> return res + is AppResult.Success -> res.data.data + } + + if (page.items.isNotEmpty()) applyPulled(resourceType, page.items) + + val next = page.nextCursor + if (next != null && next != cursor) { + cursor = next + syncCursorDao.upsert( + SyncCursorEntity( + resourceType = resourceType, + cursor = next, + lastSyncedAt = timeProvider.now(), + ), + ) + } + if (!page.hasMore) break + } + } + return AppResult.success(Unit) + } + + private suspend fun applyPulled(resourceType: String, items: List) { + when (resourceType) { + ResourceTypes.BRANCHES -> orgDao.upsertBranches( + items.decode(BranchDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.GEOFENCES -> orgDao.upsertGeofences( + items.decode(GeofenceDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.EMPLOYEES -> orgDao.upsertEmployees( + items.decode(EmployeeDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.SHIFTS -> shiftDao.upsertShifts( + items.decode(ShiftDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.SHIFT_ASSIGNMENTS -> shiftDao.upsertAssignments( + items.decode(ShiftAssignmentDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_TYPES -> leaveDao.upsertTypes( + items.decode(LeaveTypeDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_BALANCES -> leaveDao.upsertBalances( + items.decode(LeaveBalanceDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_REQUESTS -> { + items.decode(LeaveRequestDto.serializer()).forEach { dto -> + // A locally created request that hasn't pushed yet wins. + val local = leaveDao.requestById(dto.id) + if (local == null || local.syncStatus != SyncStatus.PENDING) { + leaveDao.upsertRequests(listOf(dto.toEntity())) + } + } + } + + // insertPunches uses IGNORE: pending local punches are never clobbered, + // and replayed pages are no-ops. + ResourceTypes.PUNCHES -> attendanceDao.insertPunches( + items.decode(PunchDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.ATTENDANCE_DAYS -> attendanceDao.upsertDays( + items.decode(AttendanceDayDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.PAYSLIPS -> items.decode(PayslipDto.serializer()).forEach { dto -> + payslipDao.replacePayslip(dto.toEntity(), dto.toLineEntities()) + } + + ResourceTypes.ANNOUNCEMENTS -> announcementDao.upsertAnnouncements( + items.decode(AnnouncementDto.serializer()).map { it.toEntity() }, + ) + } + } + + private fun List.decode( + serializer: kotlinx.serialization.KSerializer, + ): List = mapNotNull { item -> + try { + json.decodeFromJsonElement(serializer, item) + } catch (e: kotlinx.serialization.SerializationException) { + null // One malformed document must not poison the whole page. + } + } + + private fun AppError.toShortMessage(): String = when (this) { + AppError.Network -> "Offline" + AppError.Unauthenticated -> "Session expired" + AppError.PermissionDenied -> "Permission denied" + is AppError.Http -> "Server error ($status)" + is AppError.Business -> code + else -> "Sync failed" + } + + private companion object { + const val PUSH_BATCH_SIZE = 50 + val PUNCH_RETENTION: Duration = Duration.ofDays(90) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt b/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt new file mode 100644 index 0000000..bc03a10 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt @@ -0,0 +1,41 @@ +package app.worktrack.core.data.sync + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.entity.OutboxEntryEntity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single entry point for queueing offline mutations. Every enqueued operation + * carries a fresh ULID idempotency key so server-side replays are detectable. + */ +@Singleton +class OutboxWriter @Inject constructor( + private val outboxDao: OutboxDao, + private val timeProvider: TimeProvider, +) { + + suspend fun enqueue( + opType: String, + resourceType: String, + resourceId: String, + payloadJson: String, + ) { + outboxDao.insert( + OutboxEntryEntity( + id = Ulid.generate(), + opType = opType, + resourceType = resourceType, + resourceId = resourceId, + payloadJson = payloadJson, + idempotencyKey = Ulid.generate(), + attempts = 0, + lastError = null, + state = "PENDING", + queuedAt = timeProvider.now(), + ), + ) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt b/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt new file mode 100644 index 0000000..9da74dd --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt @@ -0,0 +1,42 @@ +package app.worktrack.core.data.sync + +/** + * Wire names for replicated resource types. Must match the backend's sync + * registry (backend/functions/src/routes/sync.ts) exactly. + */ +object ResourceTypes { + const val BRANCHES = "branches" + const val GEOFENCES = "geofences" + const val EMPLOYEES = "employees" + const val SHIFTS = "shifts" + const val SHIFT_ASSIGNMENTS = "shiftAssignments" + const val PUNCHES = "punches" + const val ATTENDANCE_DAYS = "attendanceDays" + const val LEAVE_TYPES = "leaveTypes" + const val LEAVE_BALANCES = "leaveBalances" + const val LEAVE_REQUESTS = "leaveRequests" + const val PAYSLIPS = "payslips" + const val ANNOUNCEMENTS = "announcements" + + /** Pull order: reference data first so later types can resolve foreign keys. */ + val pullOrder: List = listOf( + BRANCHES, + GEOFENCES, + EMPLOYEES, + SHIFTS, + SHIFT_ASSIGNMENTS, + LEAVE_TYPES, + LEAVE_BALANCES, + LEAVE_REQUESTS, + PUNCHES, + ATTENDANCE_DAYS, + PAYSLIPS, + ANNOUNCEMENTS, + ) +} + +object OutboxOpTypes { + const val CREATE = "CREATE" + const val UPDATE = "UPDATE" + const val DELETE = "DELETE" +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 0000000..2d3f58e --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.worktrack.android.room) +} + +android { + namespace = "app.worktrack.core.database" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt b/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt new file mode 100644 index 0000000..0e67067 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt @@ -0,0 +1,62 @@ +package app.worktrack.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import app.worktrack.core.database.converter.Converters +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.dao.SyncCursorDao +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.OutboxEntryEntity +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.database.entity.SyncCursorEntity + +@Database( + entities = [ + BranchEntity::class, + GeofenceEntity::class, + EmployeeEntity::class, + AttendancePunchEntity::class, + AttendanceDayEntity::class, + ShiftEntity::class, + ShiftAssignmentEntity::class, + LeaveTypeEntity::class, + LeaveBalanceEntity::class, + LeaveRequestEntity::class, + PayslipEntity::class, + PayslipLineEntity::class, + AnnouncementEntity::class, + OutboxEntryEntity::class, + SyncCursorEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class WorkTrackDatabase : RoomDatabase() { + abstract fun orgDao(): OrgDao + abstract fun attendanceDao(): AttendanceDao + abstract fun shiftDao(): ShiftDao + abstract fun leaveDao(): LeaveDao + abstract fun payslipDao(): PayslipDao + abstract fun announcementDao(): AnnouncementDao + abstract fun outboxDao(): OutboxDao + abstract fun syncCursorDao(): SyncCursorDao +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt new file mode 100644 index 0000000..19831ec --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt @@ -0,0 +1,34 @@ +package app.worktrack.core.database.converter + +import androidx.room.TypeConverter +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +/** + * java.time storage strategy: + * - Instant -> epoch millis (Long) — range queries stay index-friendly + * - LocalDate -> epoch day (Long) — timezone-proof calendar dates + * - LocalTime -> second of day (Int) — shift boundaries + * Enums are persisted by name via Room's built-in enum support. + */ +class Converters { + + @TypeConverter + fun instantToLong(value: Instant?): Long? = value?.toEpochMilli() + + @TypeConverter + fun longToInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli) + + @TypeConverter + fun localDateToLong(value: LocalDate?): Long? = value?.toEpochDay() + + @TypeConverter + fun longToLocalDate(value: Long?): LocalDate? = value?.let(LocalDate::ofEpochDay) + + @TypeConverter + fun localTimeToInt(value: LocalTime?): Int? = value?.toSecondOfDay() + + @TypeConverter + fun intToLocalTime(value: Int?): LocalTime? = value?.let(LocalTime::ofSecondOfDay) +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt new file mode 100644 index 0000000..a45ec83 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt @@ -0,0 +1,28 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.AnnouncementEntity +import java.time.Instant +import kotlinx.coroutines.flow.Flow + +@Dao +interface AnnouncementDao { + + @Upsert + suspend fun upsertAnnouncements(announcements: List) + + @Query( + """ + SELECT * FROM announcements + WHERE publishedAt <= :now AND (expiresAt IS NULL OR expiresAt > :now) + ORDER BY publishedAt DESC + LIMIT 100 + """, + ) + fun observeActive(now: Instant): Flow> + + @Query("DELETE FROM announcements") + suspend fun clearAnnouncements() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt new file mode 100644 index 0000000..69bbef7 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt @@ -0,0 +1,77 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +@Dao +interface AttendanceDao { + + // Punches are append-only: IGNORE keeps the first write (idempotent replays). + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPunch(punch: AttendancePunchEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPunches(punches: List) + + @Query( + """ + SELECT * FROM attendance_punches + WHERE employeeId = :employeeId AND punchedAt BETWEEN :from AND :to + ORDER BY punchedAt ASC + """, + ) + fun observePunchesBetween( + employeeId: String, + from: Instant, + to: Instant, + ): Flow> + + @Query( + """ + UPDATE attendance_punches + SET syncStatus = :syncStatus, serverValidated = :serverValidated, + invalidReason = :invalidReason + WHERE id = :id + """, + ) + suspend fun updatePunchSyncResult( + id: String, + syncStatus: SyncStatus, + serverValidated: Boolean, + invalidReason: String?, + ) + + @Query("DELETE FROM attendance_punches WHERE punchedAt < :cutoff AND syncStatus = 'SYNCED'") + suspend fun prunePunchesBefore(cutoff: Instant) + + @Upsert + suspend fun upsertDays(days: List) + + @Query( + """ + SELECT * FROM attendance_days + WHERE employeeId = :employeeId AND date BETWEEN :from AND :to + ORDER BY date DESC + """, + ) + fun observeDaysBetween( + employeeId: String, + from: LocalDate, + to: LocalDate, + ): Flow> + + @Query("DELETE FROM attendance_punches") + suspend fun clearPunches() + + @Query("DELETE FROM attendance_days") + suspend fun clearDays() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt new file mode 100644 index 0000000..247d5cc --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt @@ -0,0 +1,76 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import kotlinx.coroutines.flow.Flow + +@Dao +interface LeaveDao { + + @Upsert + suspend fun upsertTypes(types: List) + + @Query("SELECT * FROM leave_types WHERE active = 1 ORDER BY name") + fun observeActiveTypes(): Flow> + + @Upsert + suspend fun upsertBalances(balances: List) + + @Query("SELECT * FROM leave_balances WHERE employeeId = :employeeId AND periodYear = :year") + fun observeBalances(employeeId: String, year: Int): Flow> + + @Upsert + suspend fun upsertRequests(requests: List) + + @Query( + """ + SELECT * FROM leave_requests + WHERE employeeId = :employeeId + ORDER BY startDate DESC + LIMIT 200 + """, + ) + fun observeMyRequests(employeeId: String): Flow> + + @Query( + """ + SELECT * FROM leave_requests + WHERE currentApproverId = :approverId AND status = 'PENDING' + ORDER BY startDate ASC + """, + ) + fun observePendingApprovals(approverId: String): Flow> + + @Query("SELECT * FROM leave_requests WHERE id = :id") + suspend fun requestById(id: String): LeaveRequestEntity? + + @Query( + """ + UPDATE leave_requests + SET status = :status, syncStatus = :syncStatus, updatedAt = :updatedAt + WHERE id = :id + """, + ) + suspend fun updateRequestStatus( + id: String, + status: LeaveStatus, + syncStatus: SyncStatus, + updatedAt: Instant, + ) + + @Query("DELETE FROM leave_types") + suspend fun clearTypes() + + @Query("DELETE FROM leave_balances") + suspend fun clearBalances() + + @Query("DELETE FROM leave_requests") + suspend fun clearRequests() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt new file mode 100644 index 0000000..101a136 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt @@ -0,0 +1,40 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface OrgDao { + + @Upsert + suspend fun upsertBranches(branches: List) + + @Query("SELECT * FROM branches ORDER BY name") + fun observeBranches(): Flow> + + @Upsert + suspend fun upsertGeofences(geofences: List) + + @Query("SELECT * FROM geofences WHERE active = 1") + fun observeActiveGeofences(): Flow> + + @Upsert + suspend fun upsertEmployees(employees: List) + + @Query("SELECT * FROM employees WHERE id = :employeeId") + fun observeEmployee(employeeId: String): Flow + + @Query("DELETE FROM branches") + suspend fun clearBranches() + + @Query("DELETE FROM geofences") + suspend fun clearGeofences() + + @Query("DELETE FROM employees") + suspend fun clearEmployees() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt new file mode 100644 index 0000000..6c53d0b --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import app.worktrack.core.database.entity.OutboxEntryEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface OutboxDao { + + @Insert + suspend fun insert(entry: OutboxEntryEntity) + + /** Oldest-first pending work; FIFO ordering preserves causal order per resource. */ + @Query("SELECT * FROM outbox_entries WHERE state = 'PENDING' ORDER BY queuedAt ASC LIMIT :limit") + suspend fun nextPending(limit: Int): List + + @Query("UPDATE outbox_entries SET state = 'IN_FLIGHT' WHERE id IN (:ids)") + suspend fun markInFlight(ids: List) + + @Query("DELETE FROM outbox_entries WHERE id = :id") + suspend fun delete(id: String) + + @Query( + """ + UPDATE outbox_entries + SET state = :state, attempts = attempts + 1, lastError = :error + WHERE id = :id + """, + ) + suspend fun markAttemptFailed(id: String, state: String, error: String?) + + /** Recovers entries stranded IN_FLIGHT by a process death mid-sync. */ + @Query("UPDATE outbox_entries SET state = 'PENDING' WHERE state = 'IN_FLIGHT'") + suspend fun requeueInFlight() + + @Query("SELECT COUNT(*) FROM outbox_entries WHERE state IN ('PENDING', 'IN_FLIGHT')") + fun observePendingCount(): Flow + + @Query("SELECT COUNT(*) FROM outbox_entries WHERE state = 'FAILED'") + fun observeFailedCount(): Flow + + @Query("DELETE FROM outbox_entries") + suspend fun clear() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt new file mode 100644 index 0000000..5d7d453 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt @@ -0,0 +1,49 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.PayslipWithLines +import kotlinx.coroutines.flow.Flow + +@Dao +interface PayslipDao { + + @Transaction + @Query( + """ + SELECT * FROM payslips + WHERE employeeId = :employeeId AND periodYear = :year + ORDER BY periodMonth DESC + """, + ) + fun observePayslips(employeeId: String, year: Int): Flow> + + @Transaction + @Query("SELECT * FROM payslips WHERE id = :payslipId") + fun observePayslip(payslipId: String): Flow + + @Upsert + suspend fun upsertPayslips(payslips: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLines(lines: List) + + @Query("DELETE FROM payslip_lines WHERE payslipId = :payslipId") + suspend fun deleteLines(payslipId: String) + + @Transaction + suspend fun replacePayslip(payslip: PayslipEntity, lines: List) { + upsertPayslips(listOf(payslip)) + deleteLines(payslip.id) + insertLines(lines) + } + + @Query("DELETE FROM payslips") + suspend fun clearPayslips() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt new file mode 100644 index 0000000..3d108f4 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt @@ -0,0 +1,38 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +@Dao +interface ShiftDao { + + @Upsert + suspend fun upsertShifts(shifts: List) + + @Upsert + suspend fun upsertAssignments(assignments: List) + + @Query("SELECT * FROM shifts WHERE id = :shiftId") + suspend fun shiftById(shiftId: String): ShiftEntity? + + @Query( + """ + SELECT s.* FROM shifts s + INNER JOIN shift_assignments a ON a.shiftId = s.id + WHERE a.employeeId = :employeeId AND a.date = :date + LIMIT 1 + """, + ) + fun observeShiftForDate(employeeId: String, date: LocalDate): Flow + + @Query("DELETE FROM shifts") + suspend fun clearShifts() + + @Query("DELETE FROM shift_assignments") + suspend fun clearAssignments() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt new file mode 100644 index 0000000..0aa7c15 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt @@ -0,0 +1,19 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.SyncCursorEntity + +@Dao +interface SyncCursorDao { + + @Query("SELECT * FROM sync_cursors WHERE resourceType = :resourceType") + suspend fun cursor(resourceType: String): SyncCursorEntity? + + @Upsert + suspend fun upsert(cursor: SyncCursorEntity) + + @Query("DELETE FROM sync_cursors") + suspend fun clear() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt new file mode 100644 index 0000000..7f9362f --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt @@ -0,0 +1,33 @@ +package app.worktrack.core.database.di + +import android.content.Context +import androidx.room.Room +import app.worktrack.core.database.WorkTrackDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): WorkTrackDatabase = + Room.databaseBuilder(context, WorkTrackDatabase::class.java, "worktrack.db") + // v1 ships with destructive fallback OFF: schema changes require an + // explicit Migration or a failed build, never silent data loss. + .build() + + @Provides fun provideOrgDao(db: WorkTrackDatabase) = db.orgDao() + @Provides fun provideAttendanceDao(db: WorkTrackDatabase) = db.attendanceDao() + @Provides fun provideShiftDao(db: WorkTrackDatabase) = db.shiftDao() + @Provides fun provideLeaveDao(db: WorkTrackDatabase) = db.leaveDao() + @Provides fun providePayslipDao(db: WorkTrackDatabase) = db.payslipDao() + @Provides fun provideAnnouncementDao(db: WorkTrackDatabase) = db.announcementDao() + @Provides fun provideOutboxDao(db: WorkTrackDatabase) = db.outboxDao() + @Provides fun provideSyncCursorDao(db: WorkTrackDatabase) = db.syncCursorDao() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt new file mode 100644 index 0000000..1d96b7c --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt @@ -0,0 +1,86 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate + +/** Append-only local mirror of clock events. 90-day retention window on device. */ +@Entity( + tableName = "attendance_punches", + indices = [ + Index(value = ["employeeId", "punchedAt"]), + Index("syncStatus"), + ], +) +data class AttendancePunchEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val punchedAt: Instant, + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val geofenceId: String?, + val insideFence: Boolean, + val note: String?, + val serverValidated: Boolean, + val invalidReason: String?, + val syncStatus: SyncStatus, +) + +/** Server-computed daily summary; read-only on the client. */ +@Entity( + tableName = "attendance_days", + indices = [Index(value = ["employeeId", "date"], unique = true)], +) +data class AttendanceDayEntity( + @PrimaryKey val id: String, + val employeeId: String, + val date: LocalDate, + val shiftId: String?, + val firstInAt: Instant?, + val lastOutAt: Instant?, + val workedMinutes: Int, + val lateMinutes: Int, + val earlyOutMinutes: Int, + val overtimeMinutes: Int, + val status: String, +) + +@Entity(tableName = "shifts") +data class ShiftEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val startTimeSecondOfDay: Int, + val endTimeSecondOfDay: Int, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "shift_assignments", + indices = [Index(value = ["employeeId", "date"], unique = true)], +) +data class ShiftAssignmentEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + val date: LocalDate, + val branchId: String?, + val source: String, + val updatedAt: Instant, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt new file mode 100644 index 0000000..ff9aa3e --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt @@ -0,0 +1,68 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate + +@Entity(tableName = "leave_types") +data class LeaveTypeEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String, + val isPaid: Boolean, + val requiresAttachment: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "leave_balances", + indices = [Index(value = ["employeeId", "leaveTypeId", "periodYear"], unique = true)], +) +data class LeaveBalanceEntity( + @PrimaryKey val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double, + val accruedDays: Double, + val usedDays: Double, + val carriedOverDays: Double, + val pendingDays: Double, + val updatedAt: Instant, +) + +@Entity( + tableName = "leave_requests", + indices = [ + Index(value = ["employeeId", "startDate"]), + Index("status"), + Index("currentApproverId"), + ], +) +data class LeaveRequestEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String?, + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val days: Double, + val reason: String, + val status: LeaveStatus, + val currentApproverId: String?, + val decidedAt: Instant?, + val decisionNote: String?, + val createdAt: Instant, + val updatedAt: Instant, + val syncStatus: SyncStatus, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt new file mode 100644 index 0000000..8289d20 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt @@ -0,0 +1,59 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "branches") +data class BranchEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String?, + val latitude: Double?, + val longitude: Double?, + val radiusMeters: Int?, + val timezone: String, + val updatedAt: Instant, +) + +@Entity( + tableName = "geofences", + indices = [Index("branchId"), Index("active")], +) +data class GeofenceEntity( + @PrimaryKey val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "employees", + indices = [Index("branchId"), Index("managerId")], +) +data class EmployeeEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String?, + val avatarUrl: String?, + val branchId: String?, + val departmentId: String?, + val positionId: String?, + val managerId: String?, + val employmentType: String, + val joinDateEpochDay: Long, + val status: String, + val updatedAt: Instant, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt new file mode 100644 index 0000000..b797a2c --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.database.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import java.time.Instant + +@Entity( + tableName = "payslips", + indices = [Index(value = ["employeeId", "periodYear", "periodMonth"], unique = true)], +) +data class PayslipEntity( + @PrimaryKey val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double, + val paidLeaveDays: Double, + val lopDays: Double, + val overtimeMinutes: Int, + val status: String, + val pdfUrl: String?, + val updatedAt: Instant, +) + +@Entity( + tableName = "payslip_lines", + primaryKeys = ["payslipId", "componentCode"], + foreignKeys = [ + ForeignKey( + entity = PayslipEntity::class, + parentColumns = ["id"], + childColumns = ["payslipId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("payslipId")], +) +data class PayslipLineEntity( + val payslipId: String, + val componentCode: String, + val componentName: String, + val type: String, + val amount: Double, +) + +data class PayslipWithLines( + @Embedded val payslip: PayslipEntity, + @Relation(parentColumn = "id", entityColumn = "payslipId") + val lines: List, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt new file mode 100644 index 0000000..7098545 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt @@ -0,0 +1,51 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity( + tableName = "announcements", + indices = [Index("publishedAt")], +) +data class AnnouncementEntity( + @PrimaryKey val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: String, + val publishedAt: Instant, + val expiresAt: Instant?, + val createdByName: String?, + val updatedAt: Instant, +) + +/** + * Pending mutation queue (the client half of the outbox pattern). + * Drained FIFO per resource type by the sync engine; rows are deleted on ack. + */ +@Entity( + tableName = "outbox_entries", + indices = [Index(value = ["state", "queuedAt"]), Index("resourceType")], +) +data class OutboxEntryEntity( + @PrimaryKey val id: String, + val opType: String, + val resourceType: String, + val resourceId: String, + val payloadJson: String, + val idempotencyKey: String, + val attempts: Int, + val lastError: String?, + val state: String, + val queuedAt: Instant, +) + +/** Per-resource-type delta cursor for incremental pull. */ +@Entity(tableName = "sync_cursors") +data class SyncCursorEntity( + @PrimaryKey val resourceType: String, + val cursor: String, + val lastSyncedAt: Instant, +) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 0000000..ac6cd1d --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.datastore" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) +} diff --git a/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt new file mode 100644 index 0000000..5b449b0 --- /dev/null +++ b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt @@ -0,0 +1,91 @@ +package app.worktrack.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import app.worktrack.core.model.RoleCode +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Persists the resolved user session (identity + tenant + roles) across process + * restarts so the app opens straight into offline mode. Auth *tokens* are never + * stored here — the Firebase SDK owns credential storage and refresh. + */ +@Singleton +class SessionStore @Inject constructor( + private val dataStore: DataStore, +) { + + @Serializable + private data class StoredSession( + val uid: String, + val companyId: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String?, + val roles: List, + val branchIds: List, + val companyName: String, + ) + + private val json = Json { ignoreUnknownKeys = true } + + val session: Flow = dataStore.data.map { prefs -> + prefs[KEY_SESSION]?.let { raw -> + try { + json.decodeFromString(raw).toModel() + } catch (_: SerializationException) { + null // Corrupt/legacy payload: treat as signed out rather than crash. + } + } + } + + suspend fun save(session: UserSession) { + dataStore.edit { prefs -> + prefs[KEY_SESSION] = json.encodeToString(session.toStored()) + } + } + + suspend fun clear() { + dataStore.edit { prefs -> prefs.remove(KEY_SESSION) } + } + + private fun StoredSession.toModel() = UserSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.mapNotNull(RoleCode::fromCode).toSet(), + branchIds = branchIds, + companyName = companyName, + ) + + private fun UserSession.toStored() = StoredSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.map { it.name }, + branchIds = branchIds, + companyName = companyName, + ) + + private companion object { + val KEY_SESSION = stringPreferencesKey("user_session_v1") + } +} diff --git a/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt new file mode 100644 index 0000000..b5a0b03 --- /dev/null +++ b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,26 @@ +package app.worktrack.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private val Context.sessionDataStore: DataStore by preferencesDataStore( + name = "worktrack_session", +) + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideSessionDataStore(@ApplicationContext context: Context): DataStore = + context.sessionDataStore +} diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts new file mode 100644 index 0000000..446aa4b --- /dev/null +++ b/core/designsystem/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.worktrack.android.library.compose) +} + +android { + namespace = "app.worktrack.core.designsystem" +} + +dependencies { + implementation(libs.androidx.compose.material.icons) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt new file mode 100644 index 0000000..d35b16b --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt @@ -0,0 +1,63 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Primary action button with a built-in loading state: while [loading] is true + * the button is disabled and shows a spinner, preventing double submission. + */ +@Composable +fun WtPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled && !loading, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +fun WtSecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt new file mode 100644 index 0000000..6d60cbf --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt @@ -0,0 +1,56 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WtTopBar( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { Text(title, style = MaterialTheme.typography.titleLarge) }, + modifier = modifier, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + }, + actions = actions, + ) +} + +@Composable +fun SectionHeader( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt new file mode 100644 index 0000000..1abebec --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt @@ -0,0 +1,86 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun FullScreenLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun ErrorState( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + WtSecondaryButton(text = "Retry", onClick = onRetry) + } +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt new file mode 100644 index 0000000..da31820 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt @@ -0,0 +1,63 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.theme.StatusAmber +import app.worktrack.core.designsystem.theme.StatusAmberContainer +import app.worktrack.core.designsystem.theme.StatusGreen +import app.worktrack.core.designsystem.theme.StatusGreenContainer +import app.worktrack.core.designsystem.theme.StatusNeutral +import app.worktrack.core.designsystem.theme.StatusNeutralContainer +import app.worktrack.core.designsystem.theme.StatusRed +import app.worktrack.core.designsystem.theme.StatusRedContainer + +/** Semantic tone for status chips, mapped from domain enums at the call site. */ +enum class ChipTone { POSITIVE, WARNING, NEGATIVE, NEUTRAL } + +@Composable +fun StatusChip( + text: String, + tone: ChipTone, + modifier: Modifier = Modifier, +) { + val (container, content) = when (tone) { + ChipTone.POSITIVE -> StatusGreenContainer to StatusGreen + ChipTone.WARNING -> StatusAmberContainer to StatusAmber + ChipTone.NEGATIVE -> StatusRedContainer to StatusRed + ChipTone.NEUTRAL -> StatusNeutralContainer to StatusNeutral + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = content, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +@Composable +fun ColorDotChip( + text: String, + dotColor: Color, + modifier: Modifier = Modifier, +) { + Text( + text = "● $text", + style = MaterialTheme.typography.labelMedium, + color = dotColor, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt new file mode 100644 index 0000000..01cf241 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt @@ -0,0 +1,42 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.VisualTransformation + +/** Standard single-line form field with error slot wired for a11y. */ +@Composable +fun WtTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + errorText: String? = null, + enabled: Boolean = true, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = { Text(label) }, + isError = errorText != null, + supportingText = errorText?.let { + { Text(text = it, style = MaterialTheme.typography.bodySmall) } + }, + enabled = enabled, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt new file mode 100644 index 0000000..74bb258 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt @@ -0,0 +1,45 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +// WorkTrack brand palette. Teal primary conveys reliability; amber tertiary is +// reserved for attendance states (late, pending). + +val Teal10 = Color(0xFF001F24) +val Teal20 = Color(0xFF00363D) +val Teal30 = Color(0xFF004F58) +val Teal40 = Color(0xFF006874) +val Teal80 = Color(0xFF4FD8EB) +val Teal90 = Color(0xFF97F0FF) + +val Slate10 = Color(0xFF0F1417) +val Slate20 = Color(0xFF242A2D) +val Slate30 = Color(0xFF3A4043) +val Slate80 = Color(0xFFC2C7CA) +val Slate90 = Color(0xFFDEE3E6) +val Slate95 = Color(0xFFECF1F4) +val Slate99 = Color(0xFFFBFDFE) + +val Amber10 = Color(0xFF261A00) +val Amber20 = Color(0xFF402D00) +val Amber30 = Color(0xFF5C4200) +val Amber40 = Color(0xFF7A5900) +val Amber80 = Color(0xFFFABD1B) +val Amber90 = Color(0xFFFFDF9E) + +val Red10 = Color(0xFF410002) +val Red20 = Color(0xFF690005) +val Red30 = Color(0xFF93000A) +val Red40 = Color(0xFFBA1A1A) +val Red80 = Color(0xFFFFB4AB) +val Red90 = Color(0xFFFFDAD6) + +// Semantic status colors (used by StatusChip; stable across light/dark). +val StatusGreen = Color(0xFF2E7D32) +val StatusGreenContainer = Color(0xFFC8E6C9) +val StatusAmber = Color(0xFF9A6B00) +val StatusAmberContainer = Color(0xFFFFE8B3) +val StatusRed = Color(0xFFB3261E) +val StatusRedContainer = Color(0xFFF9DEDC) +val StatusNeutral = Color(0xFF49545A) +val StatusNeutralContainer = Color(0xFFE1E8ED) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt new file mode 100644 index 0000000..fe9e08e --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt @@ -0,0 +1,88 @@ +package app.worktrack.core.designsystem.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = Teal40, + onPrimary = Slate99, + primaryContainer = Teal90, + onPrimaryContainer = Teal10, + secondary = Slate30, + onSecondary = Slate99, + secondaryContainer = Slate90, + onSecondaryContainer = Slate10, + tertiary = Amber40, + onTertiary = Slate99, + tertiaryContainer = Amber90, + onTertiaryContainer = Amber10, + error = Red40, + onError = Slate99, + errorContainer = Red90, + onErrorContainer = Red10, + background = Slate99, + onBackground = Slate10, + surface = Slate99, + onSurface = Slate10, + surfaceVariant = Slate95, + onSurfaceVariant = Slate30, + outline = Slate30, +) + +private val DarkColors = darkColorScheme( + primary = Teal80, + onPrimary = Teal20, + primaryContainer = Teal30, + onPrimaryContainer = Teal90, + secondary = Slate80, + onSecondary = Slate20, + secondaryContainer = Slate30, + onSecondaryContainer = Slate90, + tertiary = Amber80, + onTertiary = Amber20, + tertiaryContainer = Amber30, + onTertiaryContainer = Amber90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = Slate10, + onBackground = Slate90, + surface = Slate10, + onSurface = Slate90, + surfaceVariant = Slate30, + onSurfaceVariant = Slate80, + outline = Slate80, +) + +@Composable +fun WorkTrackTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Brand colors by default: a workforce app should look identical across the + // fleet; dynamic color is an opt-in for personal devices. + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = WorkTrackTypography, + content = content, + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt new file mode 100644 index 0000000..8d95b74 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt @@ -0,0 +1,81 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * M3 default type scale with tightened display/headline weights. System font + * keeps APK size down and respects user font-scale accessibility settings. + */ +val WorkTrackTypography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 0000000..da1f7dd --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} + +dependencies { + api(projects.core.common) + api(projects.core.model) + implementation(libs.javax.inject) + + testImplementation(libs.turbine) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt new file mode 100644 index 0000000..bf74a32 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.Announcement +import kotlinx.coroutines.flow.Flow + +interface AnnouncementRepository { + + /** Currently visible announcements (published, not expired), newest first. */ + fun observeAnnouncements(): Flow> + + suspend fun refresh(): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt new file mode 100644 index 0000000..9591d3d --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt @@ -0,0 +1,32 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.TodayAttendance +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +interface AttendanceRepository { + + /** Live view of the current day: punches so far, clocked-in state, today's shift. */ + fun observeToday(): Flow + + fun observeDays(from: LocalDate, to: LocalDate): Flow> + + fun observePunches(from: LocalDate, to: LocalDate): Flow> + + fun observeActiveGeofences(): Flow> + + /** + * Records a punch offline-first: persists locally with PENDING sync status, + * enqueues an outbox operation, and requests an immediate sync. Never blocks + * on the network — server validation results reconcile asynchronously. + */ + suspend fun punch(command: PunchCommand): AppResult + + /** Pulls the given window of attendance days/punches from the server into Room. */ + suspend fun refresh(from: LocalDate, to: LocalDate): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..2856649 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt @@ -0,0 +1,25 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.UserSession +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + + /** Emits the current session, or null when signed out. Backed by DataStore. */ + val session: Flow + + /** + * Authenticates against Firebase Auth, then resolves tenant context via + * GET /me and persists the session locally. + */ + suspend fun signIn(email: String, password: String): AppResult + + /** Re-fetches GET /me (roles/claims may have changed) and updates the stored session. */ + suspend fun refreshSession(): AppResult + + suspend fun sendPasswordReset(email: String): AppResult + + /** Signs out of Firebase, clears the session, local database, and pending outbox. */ + suspend fun signOut() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt new file mode 100644 index 0000000..eef0c2e --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import kotlinx.coroutines.flow.Flow + +interface LeaveRepository { + + fun observeTypes(): Flow> + + fun observeMyBalances(periodYear: Int): Flow> + + fun observeMyRequests(): Flow> + + /** Requests awaiting the current user's decision. Empty for non-approvers. */ + fun observePendingApprovals(): Flow> + + /** + * Creates a request offline-first (local insert + outbox). The server is + * authoritative on balances and may reject on sync; rejection surfaces as a + * FAILED sync status plus a notification, never silent loss. + */ + suspend fun apply(application: LeaveApplication): AppResult + + suspend fun cancel(requestId: String): AppResult + + /** Approve/reject as the current approver. Requires connectivity (server-authoritative). */ + suspend fun decide(requestId: String, decision: ApprovalDecision, note: String?): AppResult + + suspend fun refresh(): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt new file mode 100644 index 0000000..055277c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.Payslip +import kotlinx.coroutines.flow.Flow + +interface PayslipRepository { + + fun observePayslips(periodYear: Int): Flow> + + fun observePayslip(payslipId: String): Flow + + suspend fun refresh(periodYear: Int): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt new file mode 100644 index 0000000..2402429 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt @@ -0,0 +1,31 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.SyncState +import kotlinx.coroutines.flow.Flow + +/** + * The client sync engine: drains the outbox (push) then applies server deltas (pull). + * Invoked by WorkManager; UI observes [observeSyncState] for health. + */ +interface SyncRepository { + + fun observeSyncState(): Flow + + /** + * One full sync cycle: push pending outbox operations in FIFO order per + * resource, then delta-pull every replicated resource type. Idempotent — + * safe to call concurrently or repeatedly. + */ + suspend fun syncNow(): AppResult +} + +/** Schedules sync work; implemented with WorkManager in :core:sync. */ +interface SyncScheduler { + + /** Ensures the periodic background sync is registered (idempotent). */ + fun schedulePeriodicSync() + + /** Requests an expedited one-off sync, e.g. right after a punch. */ + fun requestImmediateSync() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt new file mode 100644 index 0000000..057afdd --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt @@ -0,0 +1,55 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.geo.GeoDistance +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.Geofence +import javax.inject.Inject +import kotlinx.coroutines.flow.first + +/** + * Result of matching a device location against the company's active geofences. + * + * @property fencesConfigured false when the tenant has no active fences, in which + * case punching from anywhere is permitted (small businesses without offices). + */ +data class GeofenceEvaluation( + val fencesConfigured: Boolean, + val nearestFence: Geofence?, + val distanceMeters: Double?, + val insideFence: Boolean, +) + +class EvaluateGeofenceUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + + suspend operator fun invoke( + latitude: Double, + longitude: Double, + accuracyMeters: Float?, + ): GeofenceEvaluation { + val fences = attendanceRepository.observeActiveGeofences().first() + if (fences.isEmpty()) { + return GeofenceEvaluation( + fencesConfigured = false, + nearestFence = null, + distanceMeters = null, + insideFence = false, + ) + } + + val (nearest, distance) = fences + .map { it to GeoDistance.meters(latitude, longitude, it.latitude, it.longitude) } + .minBy { (_, d) -> d } + + // GPS accuracy is credited toward the fence: a reading whose error circle + // overlaps the fence counts as inside, so poor urban GPS doesn't lock people out. + val effectiveDistance = distance - (accuracyMeters ?: 0f) + return GeofenceEvaluation( + fencesConfigured = true, + nearestFence = nearest, + distanceMeters = distance, + insideFence = effectiveDistance <= nearest.radiusMeters, + ) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt new file mode 100644 index 0000000..042854c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import java.time.YearMonth +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveAttendanceHistoryUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + + /** Attendance days for one calendar month, newest first (per DAO ordering). */ + operator fun invoke(month: YearMonth): Flow> = + attendanceRepository.observeDays(month.atDay(1), month.atEndOfMonth()) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt new file mode 100644 index 0000000..13b4732 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.TodayAttendance +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveTodayAttendanceUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + operator fun invoke(): Flow = attendanceRepository.observeToday() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt new file mode 100644 index 0000000..cc1ff93 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt @@ -0,0 +1,73 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchMethod +import javax.inject.Inject + +/** + * Client-side gate for recording a punch. The server re-validates everything; + * these checks exist to fail fast with actionable feedback while offline. + */ +class PunchClockUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, + private val evaluateGeofence: EvaluateGeofenceUseCase, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(command: PunchCommand): AppResult { + if (command.isMockLocation) { + return AppResult.failure( + AppError.Business( + code = "MOCK_LOCATION", + message = "Mock locations are not allowed for attendance", + ), + ) + } + + val enriched = when (command.method) { + PunchMethod.GPS -> { + val lat = command.latitude + val lng = command.longitude + if (lat == null || lng == null) { + return AppResult.failure( + AppError.Validation("A location fix is required for GPS punch"), + ) + } + val evaluation = evaluateGeofence(lat, lng, command.accuracyMeters) + if (evaluation.fencesConfigured && !evaluation.insideFence) { + return AppResult.failure( + AppError.Business( + code = "GEOFENCE_VIOLATION", + message = "You are outside the allowed work area" + + (evaluation.nearestFence?.let { " (${it.name})" } ?: ""), + ), + ) + } + command.copy( + geofenceId = evaluation.nearestFence?.id, + insideFence = evaluation.insideFence, + ) + } + + PunchMethod.QR -> { + if (command.kioskToken.isNullOrBlank()) { + return AppResult.failure( + AppError.Validation("Kiosk QR token missing — rescan the code"), + ) + } + command + } + + else -> command + } + + return attendanceRepository.punch(enriched) + .onSuccess { syncScheduler.requestImmediateSync() } + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt new file mode 100644 index 0000000..0c1da3e --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveSessionUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + operator fun invoke(): Flow = authRepository.session +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt new file mode 100644 index 0000000..3c36af2 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt @@ -0,0 +1,40 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.UserSession +import javax.inject.Inject + +class SignInUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(email: String, password: String): AppResult { + val trimmedEmail = email.trim() + val fieldErrors = buildMap { + if (!EMAIL_REGEX.matches(trimmedEmail)) put("email", "Enter a valid email address") + if (password.length < MIN_PASSWORD_LENGTH) { + put("password", "Password must be at least $MIN_PASSWORD_LENGTH characters") + } + } + if (fieldErrors.isNotEmpty()) { + return AppResult.failure(AppError.Validation("Check your credentials", fieldErrors)) + } + return authRepository.signIn(trimmedEmail, password) + .also { result -> + if (result is AppResult.Success) { + // First sign-in on a device triggers the initial bootstrap sync. + syncScheduler.schedulePeriodicSync() + syncScheduler.requestImmediateSync() + } + } + } + + private companion object { + const val MIN_PASSWORD_LENGTH = 8 + val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$") + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt new file mode 100644 index 0000000..0f789e7 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt @@ -0,0 +1,10 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.domain.repository.AuthRepository +import javax.inject.Inject + +class SignOutUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke() = authRepository.signOut() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt new file mode 100644 index 0000000..ad4d5a8 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt @@ -0,0 +1,51 @@ +package app.worktrack.core.domain.usecase.dashboard + +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.TodayAttendance +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +data class DashboardSnapshot( + val session: UserSession, + val today: TodayAttendance, + val leaveBalances: List, + val announcements: List, +) + +class ObserveDashboardUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val attendanceRepository: AttendanceRepository, + private val leaveRepository: LeaveRepository, + private val announcementRepository: AnnouncementRepository, + private val timeProvider: TimeProvider, +) { + + /** Emits null while signed out; the app shell redirects to auth in that case. */ + operator fun invoke(): Flow = combine( + authRepository.session, + attendanceRepository.observeToday(), + leaveRepository.observeMyBalances(timeProvider.today().year), + announcementRepository.observeAnnouncements(), + ) { session, today, balances, announcements -> + session?.let { + DashboardSnapshot( + session = it, + today = today, + leaveBalances = balances, + announcements = announcements.take(MAX_DASHBOARD_ANNOUNCEMENTS), + ) + } + } + + private companion object { + const val MAX_DASHBOARD_ANNOUNCEMENTS = 5 + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt new file mode 100644 index 0000000..b58a813 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt @@ -0,0 +1,82 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveRequest +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlinx.coroutines.flow.first + +class ApplyLeaveUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, + private val timeProvider: TimeProvider, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(application: LeaveApplication): AppResult { + val fieldErrors = buildMap { + if (application.endDate.isBefore(application.startDate)) { + put("endDate", "End date must be on or after the start date") + } + if (application.reason.isBlank()) put("reason", "A reason is required") + if (application.startDate.isBefore(timeProvider.today().minusDays(MAX_BACKDATE_DAYS))) { + put("startDate", "Leave cannot start more than $MAX_BACKDATE_DAYS days in the past") + } + } + if (fieldErrors.isNotEmpty()) { + return AppResult.failure(AppError.Validation("Fix the highlighted fields", fieldErrors)) + } + + val days = calculateDays(application) + if (days <= 0.0) { + return AppResult.failure(AppError.Validation("The selected range is empty")) + } + + // Best-effort local balance check for immediate feedback; the server holds + // the authoritative balance and re-validates on sync. + val balance = leaveRepository + .observeMyBalances(application.startDate.year).first() + .firstOrNull { it.leaveTypeId == application.leaveTypeId } + if (balance != null && days > balance.availableDays) { + return AppResult.failure( + AppError.Business( + code = "INSUFFICIENT_LEAVE_BALANCE", + message = "Requested %.1f days but only %.1f available" + .format(days, balance.availableDays), + ), + ) + } + + return leaveRepository.apply(application) + .onSuccess { syncScheduler.requestImmediateSync() } + } + + companion object { + private const val MAX_BACKDATE_DAYS = 30L + + /** + * Calendar-day count with half-day adjustments. Weekend/holiday exclusion + * depends on branch calendars and is applied server-side; this figure is + * the client-side estimate shown before submission. + */ + fun calculateDays(application: LeaveApplication): Double { + val span = ChronoUnit.DAYS.between(application.startDate, application.endDate) + 1 + if (span <= 0) return 0.0 + if (isSingleDay(application.startDate, application.endDate)) { + return if (application.startHalfDay || application.endHalfDay) 0.5 else 1.0 + } + var days = span.toDouble() + if (application.startHalfDay) days -= 0.5 + if (application.endHalfDay) days -= 0.5 + return days + } + + private fun isSingleDay(start: LocalDate, end: LocalDate) = start == end + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt new file mode 100644 index 0000000..dae7e3b --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.LeaveRepository +import javax.inject.Inject + +class CancelLeaveRequestUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + suspend operator fun invoke(requestId: String): AppResult = + leaveRepository.cancel(requestId) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt new file mode 100644 index 0000000..3ec096b --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt @@ -0,0 +1,25 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.ApprovalDecision +import javax.inject.Inject + +class DecideLeaveRequestUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + + suspend operator fun invoke( + requestId: String, + decision: ApprovalDecision, + note: String?, + ): AppResult { + if (decision == ApprovalDecision.REJECT && note.isNullOrBlank()) { + return AppResult.failure( + AppError.Validation("A note is required when rejecting", mapOf("note" to "Required")), + ) + } + return leaveRepository.decide(requestId, decision, note?.trim()?.takeIf { it.isNotEmpty() }) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt new file mode 100644 index 0000000..72d97b4 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt @@ -0,0 +1,33 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** Everything the leave screen needs, joined so type metadata is always resolvable. */ +data class LeaveOverview( + val types: List, + val balances: List, + val myRequests: List, +) { + fun typeOf(leaveTypeId: String): LeaveType? = types.firstOrNull { it.id == leaveTypeId } +} + +class ObserveLeaveOverviewUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, + private val timeProvider: TimeProvider, +) { + + operator fun invoke(): Flow = combine( + leaveRepository.observeTypes(), + leaveRepository.observeMyBalances(timeProvider.today().year), + leaveRepository.observeMyRequests(), + ) { types, balances, requests -> + LeaveOverview(types = types, balances = balances, myRequests = requests) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt new file mode 100644 index 0000000..e2204b9 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.LeaveRequest +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePendingApprovalsUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + operator fun invoke(): Flow> = leaveRepository.observePendingApprovals() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt new file mode 100644 index 0000000..6821e88 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.usecase.payslip + +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePayslipDetailUseCase @Inject constructor( + private val payslipRepository: PayslipRepository, +) { + operator fun invoke(payslipId: String): Flow = + payslipRepository.observePayslip(payslipId) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt new file mode 100644 index 0000000..e2b5c76 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.usecase.payslip + +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePayslipsUseCase @Inject constructor( + private val payslipRepository: PayslipRepository, +) { + operator fun invoke(periodYear: Int): Flow> = + payslipRepository.observePayslips(periodYear) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt new file mode 100644 index 0000000..d890d2c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.sync + +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.model.SyncState +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveSyncStateUseCase @Inject constructor( + private val syncRepository: SyncRepository, +) { + operator fun invoke(): Flow = syncRepository.observeSyncState() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt new file mode 100644 index 0000000..f8556ba --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt @@ -0,0 +1,10 @@ +package app.worktrack.core.domain.usecase.sync + +import app.worktrack.core.domain.repository.SyncScheduler +import javax.inject.Inject + +class TriggerSyncUseCase @Inject constructor( + private val syncScheduler: SyncScheduler, +) { + operator fun invoke() = syncScheduler.requestImmediateSync() +} diff --git a/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt new file mode 100644 index 0000000..995eacd --- /dev/null +++ b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt @@ -0,0 +1,101 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.TodayAttendance +import java.time.Instant +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EvaluateGeofenceUseCaseTest { + + private class FakeAttendanceRepository( + private val fences: List, + ) : AttendanceRepository { + override fun observeToday(): Flow = emptyFlow() + override fun observeDays(from: LocalDate, to: LocalDate): Flow> = emptyFlow() + override fun observePunches(from: LocalDate, to: LocalDate): Flow> = emptyFlow() + override fun observeActiveGeofences(): Flow> = flowOf(fences) + override suspend fun punch(command: PunchCommand): AppResult = + error("not used in this test") + override suspend fun refresh(from: LocalDate, to: LocalDate): AppResult = + AppResult.success(Unit) + } + + private fun fence(id: String, lat: Double, lng: Double, radius: Int) = Geofence( + id = id, + companyId = "c1", + branchId = "b1", + name = "HQ", + latitude = lat, + longitude = lng, + radiusMeters = radius, + active = true, + updatedAt = Instant.EPOCH, + ) + + @Test + fun `no fences configured permits punching anywhere`() = runTest { + val useCase = EvaluateGeofenceUseCase(FakeAttendanceRepository(emptyList())) + val result = useCase(34.5553, 69.2075, accuracyMeters = 10f) + assertFalse(result.fencesConfigured) + assertFalse(result.insideFence) + } + + @Test + fun `inside radius is detected`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 150))), + ) + // ~55m east of the fence center at this latitude. + val result = useCase(34.5553, 69.2081, accuracyMeters = 5f) + assertTrue(result.insideFence) + assertEquals("f1", result.nearestFence?.id) + } + + @Test + fun `far outside radius is rejected`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 100))), + ) + // ~1.1km away. + val result = useCase(34.5553, 69.2195, accuracyMeters = 5f) + assertFalse(result.insideFence) + } + + @Test + fun `gps accuracy is credited toward the fence`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 100))), + ) + // ~155m out, but a 60m error circle overlaps the fence. + val result = useCase(34.5553, 69.2092, accuracyMeters = 60f) + assertTrue(result.insideFence) + } + + @Test + fun `nearest of multiple fences wins`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository( + listOf( + fence("far", 34.60, 69.30, radius = 100), + fence("near", 34.5553, 69.2075, radius = 100), + ), + ), + ) + val result = useCase(34.5554, 69.2076, accuracyMeters = 5f) + assertEquals("near", result.nearestFence?.id) + assertTrue(result.insideFence) + } +} diff --git a/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt new file mode 100644 index 0000000..f2bbc01 --- /dev/null +++ b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.model.LeaveApplication +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class ApplyLeaveUseCaseTest { + + private fun application( + start: LocalDate, + end: LocalDate, + startHalf: Boolean = false, + endHalf: Boolean = false, + ) = LeaveApplication( + leaveTypeId = "lt-1", + startDate = start, + endDate = end, + startHalfDay = startHalf, + endHalfDay = endHalf, + reason = "Family event", + ) + + @Test + fun `full single day counts as one`() { + val app = application(LocalDate.of(2026, 7, 20), LocalDate.of(2026, 7, 20)) + assertEquals(1.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `half single day counts as half regardless of which flag`() { + val start = LocalDate.of(2026, 7, 20) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, startHalf = true)), 0.0) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, endHalf = true)), 0.0) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, startHalf = true, endHalf = true)), 0.0) + } + + @Test + fun `inclusive multi day range`() { + val app = application(LocalDate.of(2026, 7, 20), LocalDate.of(2026, 7, 24)) + assertEquals(5.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `half days trim both ends of a range`() { + val app = application( + LocalDate.of(2026, 7, 20), + LocalDate.of(2026, 7, 24), + startHalf = true, + endHalf = true, + ) + assertEquals(4.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `inverted range yields zero`() { + val app = application(LocalDate.of(2026, 7, 24), LocalDate.of(2026, 7, 20)) + assertEquals(0.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } +} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 0000000..05d3c10 --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt new file mode 100644 index 0000000..d6df37e --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt @@ -0,0 +1,17 @@ +package app.worktrack.core.model + +import java.time.Instant + +enum class AnnouncementPriority { NORMAL, IMPORTANT, URGENT } + +data class Announcement( + val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: AnnouncementPriority, + val publishedAt: Instant, + val expiresAt: Instant?, + val createdByName: String?, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt new file mode 100644 index 0000000..295e67f --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt @@ -0,0 +1,72 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +enum class PunchType { IN, OUT } + +enum class PunchMethod { GPS, QR, FACE, MANUAL, KIOSK } + +/** A single clock event. Append-only: punches are never edited or deleted. */ +data class AttendancePunch( + val id: String, + val companyId: String, + val employeeId: String, + val punchedAt: Instant, + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val geofenceId: String?, + val insideFence: Boolean, + val note: String?, + val serverValidated: Boolean, + val invalidReason: String?, + val syncStatus: SyncStatus, +) + +enum class AttendanceDayStatus { PRESENT, ABSENT, HALF_DAY, LEAVE, HOLIDAY, WEEK_OFF, PENDING } + +/** Server-computed daily projection; the client never derives payroll-relevant minutes. */ +data class AttendanceDay( + val id: String, + val employeeId: String, + val date: LocalDate, + val shiftId: String?, + val firstInAt: Instant?, + val lastOutAt: Instant?, + val workedMinutes: Int, + val lateMinutes: Int, + val earlyOutMinutes: Int, + val overtimeMinutes: Int, + val status: AttendanceDayStatus, +) + +/** + * Input for the punch use case, built by the punch screen. Geofence fields are + * stamped by the use case after evaluation; the server re-validates regardless. + */ +data class PunchCommand( + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val isMockLocation: Boolean, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val kioskToken: String? = null, + val note: String? = null, +) + +/** Live view of "where the user stands right now" for dashboard + punch screen. */ +data class TodayAttendance( + val date: LocalDate, + val clockedIn: Boolean, + val firstInAt: Instant?, + val lastPunchAt: Instant?, + val punchCount: Int, + val workedMinutesSoFar: Int, + val shift: Shift?, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt new file mode 100644 index 0000000..bb89f7b --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt @@ -0,0 +1,73 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +enum class EmploymentType { FULL_TIME, PART_TIME, CONTRACT, INTERN } + +enum class EmployeeStatus { ACTIVE, ON_LEAVE, SUSPENDED, EXITED } + +/** Built-in platform roles; custom roles resolve to permission sets server-side. */ +enum class RoleCode { + SUPER_ADMIN, + COMPANY_ADMIN, + HR_ADMIN, + PAYROLL_ADMIN, + BRANCH_MANAGER, + TEAM_LEAD, + EMPLOYEE, + AUDITOR, + KIOSK, + ; + + companion object { + fun fromCode(code: String): RoleCode? = entries.firstOrNull { it.name == code } + } +} + +data class Employee( + val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String?, + val avatarUrl: String?, + val branchId: String?, + val departmentId: String?, + val positionId: String?, + val managerId: String?, + val employmentType: EmploymentType, + val joinDate: LocalDate, + val status: EmployeeStatus, + val updatedAt: Instant, +) { + val fullName: String get() = "$firstName $lastName".trim() +} + +/** + * The authenticated user's resolved context: identity plus tenant scoping and + * roles from Firebase custom claims, refreshed from GET /me. + */ +data class UserSession( + val uid: String, + val companyId: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String?, + val roles: Set, + val branchIds: List, + val companyName: String, +) { + fun hasAnyRole(vararg candidates: RoleCode): Boolean = candidates.any { it in roles } + + val isApprover: Boolean + get() = hasAnyRole( + RoleCode.COMPANY_ADMIN, + RoleCode.HR_ADMIN, + RoleCode.BRANCH_MANAGER, + RoleCode.TEAM_LEAD, + ) +} diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt new file mode 100644 index 0000000..dd49005 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt @@ -0,0 +1,75 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +data class LeaveType( + val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String, + val isPaid: Boolean, + val requiresAttachment: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +data class LeaveBalance( + val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double, + val accruedDays: Double, + val usedDays: Double, + val carriedOverDays: Double, + val pendingDays: Double, + val updatedAt: Instant, +) { + val availableDays: Double + get() = entitledDays + accruedDays + carriedOverDays - usedDays - pendingDays +} + +enum class LeaveStatus { DRAFT, PENDING, APPROVED, REJECTED, CANCELLED } + +data class LeaveRequest( + val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String?, + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val days: Double, + val reason: String, + val status: LeaveStatus, + val currentApproverId: String?, + val decidedAt: Instant?, + val decisionNote: String?, + val createdAt: Instant, + val updatedAt: Instant, + val syncStatus: SyncStatus, +) + +enum class ApprovalDecision { APPROVE, REJECT } + +/** Input for the apply-leave use case. */ +data class LeaveApplication( + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val reason: String, +) + +data class Holiday( + val id: String, + val calendarId: String, + val date: LocalDate, + val name: String, + val isOptional: Boolean, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt new file mode 100644 index 0000000..112d981 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt @@ -0,0 +1,52 @@ +package app.worktrack.core.model + +import java.time.Instant + +data class Company( + val id: String, + val name: String, + val legalName: String?, + val timezone: String, + val currency: String, +) + +data class Branch( + val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String?, + val latitude: Double?, + val longitude: Double?, + val radiusMeters: Int?, + val timezone: String, + val updatedAt: Instant, +) + +data class Geofence( + val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + val updatedAt: Instant, +) + +data class Department( + val id: String, + val companyId: String, + val branchId: String?, + val name: String, + val code: String, +) + +data class Position( + val id: String, + val companyId: String, + val title: String, + val code: String, + val level: Int?, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt new file mode 100644 index 0000000..c7ef937 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.model + +import java.time.Instant + +enum class PayComponentType { EARNING, DEDUCTION, EMPLOYER_COST } + +enum class PayslipStatus { DRAFT, FINALIZED, PAID } + +data class PayslipLine( + val componentCode: String, + val componentName: String, + val type: PayComponentType, + val amount: Double, +) + +data class Payslip( + val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double, + val paidLeaveDays: Double, + val lopDays: Double, + val overtimeMinutes: Int, + val status: PayslipStatus, + val pdfUrl: String?, + val lines: List, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt new file mode 100644 index 0000000..b7c6a38 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt @@ -0,0 +1,33 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +data class Shift( + val id: String, + val companyId: String, + val name: String, + val code: String, + val startTime: LocalTime, + val endTime: LocalTime, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +enum class ShiftAssignmentSource { ROSTER, ROTATION, MANUAL, SWAP } + +data class ShiftAssignment( + val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + val date: LocalDate, + val branchId: String?, + val source: ShiftAssignmentSource, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt new file mode 100644 index 0000000..7eb53a7 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt @@ -0,0 +1,15 @@ +package app.worktrack.core.model + +import java.time.Instant + +/** Client-side replication status of a locally stored row. */ +enum class SyncStatus { SYNCED, PENDING, FAILED } + +/** Aggregate health of the sync engine, surfaced in Profile and debug UIs. */ +data class SyncState( + val isSyncing: Boolean, + val pendingOperations: Int, + val failedOperations: Int, + val lastSuccessAt: Instant?, + val lastError: String?, +) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 0000000..e7f511e --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.network" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + + implementation(libs.kotlinx.coroutines.android) + api(libs.kotlinx.serialization.json) + api(libs.retrofit.core) + implementation(libs.retrofit.kotlinx.serialization) + implementation(libs.okhttp.core) + implementation(libs.okhttp.logging) +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt b/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt new file mode 100644 index 0000000..20e22dd --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.network + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.network.dto.ProblemDto +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import retrofit2.HttpException + +private val problemJson = Json { ignoreUnknownKeys = true } + +/** + * Runs one API call and converts transport/protocol failures into the app-wide + * [AppError] taxonomy. The only place HttpException/IOException are handled. + */ +suspend fun apiCall(block: suspend () -> T): AppResult = try { + AppResult.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: HttpException) { + AppResult.failure(e.toAppError()) +} catch (e: IOException) { + AppResult.failure(AppError.Network) +} catch (e: SerializationException) { + AppResult.failure(AppError.Unexpected(e)) +} + +private fun HttpException.toAppError(): AppError { + val problem = try { + response()?.errorBody()?.string() + ?.takeIf { it.isNotBlank() } + ?.let { problemJson.decodeFromString(it) } + } catch (_: SerializationException) { + null + } + + return when (code()) { + 401 -> AppError.Unauthenticated + 403 -> AppError.PermissionDenied + 404 -> AppError.NotFound + 400, 422 -> + if (problem?.code != null && problem.fieldErrors.isEmpty()) { + AppError.Business(problem.code, problem.detail ?: problem.title ?: "Request rejected") + } else { + AppError.Validation( + message = problem?.detail ?: problem?.title ?: "Invalid request", + fieldErrors = problem?.fieldErrors.orEmpty(), + ) + } + + 409 -> AppError.Business( + code = problem?.code ?: "CONFLICT", + message = problem?.detail ?: "The resource changed on the server", + ) + + else -> AppError.Http(code(), problem?.code, problem?.detail ?: problem?.title) + } +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt b/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt new file mode 100644 index 0000000..89b10b6 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt @@ -0,0 +1,61 @@ +package app.worktrack.core.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +interface NetworkMonitor { + val isOnline: Flow +} + +@Singleton +class ConnectivityNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) : NetworkMonitor { + + override val isOnline: Flow = callbackFlow { + val manager = context.getSystemService(ConnectivityManager::class.java) + + fun currentlyOnline(): Boolean { + val network = manager.activeNetwork ?: return false + val caps = manager.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(currentlyOnline()) + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + } + } + + manager.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + callback, + ) + trySend(currentlyOnline()) + + awaitClose { manager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt b/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt new file mode 100644 index 0000000..0f311b3 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt @@ -0,0 +1,71 @@ +package app.worktrack.core.network + +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.ApiEnvelope +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.LeaveDecisionDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.MeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.SyncPullResponseDto +import app.worktrack.core.network.dto.SyncPushRequestDto +import app.worktrack.core.network.dto.SyncPushResponseDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * WorkTrack REST API v1. Offline-capable mutations flow through POST /sync/push + * (batched outbox operations); the endpoints here are session resolution, + * windowed reads, and online-only decisions. + */ +interface WorkTrackApi { + + @GET("me") + suspend fun me(): ApiEnvelope + + @GET("attendance/days") + suspend fun attendanceDays( + @Query("from") from: String, // ISO date + @Query("to") to: String, + ): ApiEnvelope> + + @GET("payslips") + suspend fun payslips( + @Query("year") year: Int, + ): ApiEnvelope> + + @GET("announcements") + suspend fun announcements(): ApiEnvelope> + + @GET("leave/requests") + suspend fun leaveRequests( + @Query("scope") scope: String, // mine | approvals + ): ApiEnvelope> + + @POST("leave/requests/{id}/decide") + suspend fun decideLeaveRequest( + @Path("id") requestId: String, + @Body body: LeaveDecisionDto, + @Header("Idempotency-Key") idempotencyKey: String, + ): ApiEnvelope + + @POST("leave/requests/{id}/cancel") + suspend fun cancelLeaveRequest( + @Path("id") requestId: String, + @Header("Idempotency-Key") idempotencyKey: String, + ): ApiEnvelope + + @POST("sync/push") + suspend fun syncPush(@Body body: SyncPushRequestDto): ApiEnvelope + + @GET("sync/pull") + suspend fun syncPull( + @Query("type") resourceType: String, + @Query("cursor") cursor: String?, + @Query("limit") limit: Int = 500, + ): ApiEnvelope +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt b/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt new file mode 100644 index 0000000..9a7f6ee --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.network.auth + +/** + * Supplies the bearer token for API calls. Implemented over Firebase Auth in + * :core:data so that :core:network stays free of the Firebase dependency. + */ +interface AuthTokenProvider { + + /** + * Returns a currently valid ID token, refreshing if needed, or null when + * signed out. Must be safe to call from any thread. + */ + suspend fun idToken(forceRefresh: Boolean = false): String? +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt new file mode 100644 index 0000000..3409f68 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt @@ -0,0 +1,81 @@ +package app.worktrack.core.network.di + +import android.content.Context +import android.content.pm.ApplicationInfo +import app.worktrack.core.network.ConnectivityNetworkMonitor +import app.worktrack.core.network.NetworkMonitor +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.interceptor.AuthInterceptor +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.TimeUnit +import javax.inject.Singleton +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** Base URL for the versioned API; supplied by the app module per build variant. */ +data class ApiConfig(val baseUrl: String) + +@Module +@InstallIn(SingletonComponent::class) +internal interface NetworkBindings { + @Binds + fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor +} + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true // additive API evolution must not break old clients + explicitNulls = false + coerceInputValues = true + } + + @Provides + @Singleton + fun provideOkHttpClient( + @ApplicationContext context: Context, + authInterceptor: AuthInterceptor, + ): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + + val debuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + if (debuggable) { + // BASIC only: request lines are useful in development, bodies may hold PII. + builder.addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }, + ) + } + return builder.build() + } + + @Provides + @Singleton + fun provideRetrofit(config: ApiConfig, client: OkHttpClient, json: Json): Retrofit = + Retrofit.Builder() + .baseUrl(config.baseUrl) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideWorkTrackApi(retrofit: Retrofit): WorkTrackApi = + retrofit.create(WorkTrackApi::class.java) +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt new file mode 100644 index 0000000..c73e8d1 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt @@ -0,0 +1,58 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +/** Client -> server punch payload (also the outbox payload for punch ops). */ +@Serializable +data class PunchCreateDto( + val id: String, // client-generated ULID; doubles as the idempotency scope + @Serializable(InstantSerializer::class) val punchedAt: Instant, + val type: String, + val method: String, + val latitude: Double? = null, + val longitude: Double? = null, + val accuracyMeters: Float? = null, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val kioskToken: String? = null, + val note: String? = null, +) + +@Serializable +data class PunchDto( + val id: String, + val companyId: String, + val employeeId: String, + @Serializable(InstantSerializer::class) val punchedAt: Instant, + val type: String, + val method: String, + val latitude: Double? = null, + val longitude: Double? = null, + val accuracyMeters: Float? = null, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val note: String? = null, + val serverValidated: Boolean = false, + val invalidReason: String? = null, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class AttendanceDayDto( + val id: String, + val employeeId: String, + @Serializable(LocalDateSerializer::class) val date: LocalDate, + val shiftId: String? = null, + @Serializable(InstantSerializer::class) val firstInAt: Instant? = null, + @Serializable(InstantSerializer::class) val lastOutAt: Instant? = null, + val workedMinutes: Int = 0, + val lateMinutes: Int = 0, + val earlyOutMinutes: Int = 0, + val overtimeMinutes: Int = 0, + val status: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt new file mode 100644 index 0000000..706657f --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt @@ -0,0 +1,27 @@ +package app.worktrack.core.network.dto + +import kotlinx.serialization.Serializable + +/** Standard success envelope: { "data": ..., "meta": { "cursor": ... } }. */ +@Serializable +data class ApiEnvelope( + val data: T, + val meta: ApiMeta? = null, +) + +@Serializable +data class ApiMeta( + val cursor: String? = null, + val hasMore: Boolean = false, +) + +/** RFC 7807 problem+json error body produced by the API. */ +@Serializable +data class ProblemDto( + val type: String? = null, + val title: String? = null, + val status: Int? = null, + val code: String? = null, + val detail: String? = null, + val fieldErrors: Map = emptyMap(), +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt new file mode 100644 index 0000000..3896682 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt @@ -0,0 +1,73 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +data class LeaveTypeDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String = "#607D8B", + val isPaid: Boolean = true, + val requiresAttachment: Boolean = false, + val active: Boolean = true, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class LeaveBalanceDto( + val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double = 0.0, + val accruedDays: Double = 0.0, + val usedDays: Double = 0.0, + val carriedOverDays: Double = 0.0, + val pendingDays: Double = 0.0, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +/** Client -> server leave request payload (also the outbox payload). */ +@Serializable +data class LeaveRequestCreateDto( + val id: String, // client-generated ULID + val leaveTypeId: String, + @Serializable(LocalDateSerializer::class) val startDate: LocalDate, + @Serializable(LocalDateSerializer::class) val endDate: LocalDate, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val reason: String, +) + +@Serializable +data class LeaveRequestDto( + val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String? = null, + val leaveTypeId: String, + @Serializable(LocalDateSerializer::class) val startDate: LocalDate, + @Serializable(LocalDateSerializer::class) val endDate: LocalDate, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val days: Double, + val reason: String, + val status: String, + val currentApproverId: String? = null, + @Serializable(InstantSerializer::class) val decidedAt: Instant? = null, + val decisionNote: String? = null, + @Serializable(InstantSerializer::class) val createdAt: Instant, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class LeaveDecisionDto( + val decision: String, // APPROVE | REJECT + val note: String? = null, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt new file mode 100644 index 0000000..d654742 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt @@ -0,0 +1,82 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +data class BranchDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val radiusMeters: Int? = null, + val timezone: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class GeofenceDto( + val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class EmployeeDto( + val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String? = null, + val avatarUrl: String? = null, + val branchId: String? = null, + val departmentId: String? = null, + val positionId: String? = null, + val managerId: String? = null, + val employmentType: String, + @Serializable(LocalDateSerializer::class) val joinDate: LocalDate, + val status: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class ShiftDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val startTime: String, // "HH:mm" + val endTime: String, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class ShiftAssignmentDto( + val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + @Serializable(LocalDateSerializer::class) val date: LocalDate, + val branchId: String? = null, + val source: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt new file mode 100644 index 0000000..1baa8c9 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import java.time.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class PayslipLineDto( + val componentCode: String, + val componentName: String, + val type: String, + val amount: Double, +) + +@Serializable +data class PayslipDto( + val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double = 0.0, + val paidLeaveDays: Double = 0.0, + val lopDays: Double = 0.0, + val overtimeMinutes: Int = 0, + val status: String, + val pdfUrl: String? = null, + val lines: List = emptyList(), + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt new file mode 100644 index 0000000..f9462f7 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt @@ -0,0 +1,59 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import java.time.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class AnnouncementDto( + val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: String = "NORMAL", + @Serializable(InstantSerializer::class) val publishedAt: Instant, + @Serializable(InstantSerializer::class) val expiresAt: Instant? = null, + val createdByName: String? = null, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +/** One queued mutation from the client outbox. */ +@Serializable +data class SyncOpDto( + val opId: String, + val opType: String, // CREATE | UPDATE | DELETE + val resourceType: String, // punches | leaveRequests | ... + val resourceId: String, + val idempotencyKey: String, + val payload: JsonObject, +) + +@Serializable +data class SyncPushRequestDto( + val ops: List, +) + +/** Per-op outcome; APPLIED covers idempotent replays of already-applied ops. */ +@Serializable +data class SyncOpResultDto( + val opId: String, + val status: String, // APPLIED | REJECTED + val errorCode: String? = null, + val message: String? = null, + val resource: JsonObject? = null, +) + +@Serializable +data class SyncPushResponseDto( + val results: List, +) + +/** Delta page for one resource type. Items are raw documents mapped per type. */ +@Serializable +data class SyncPullResponseDto( + val resourceType: String, + val items: List = emptyList(), + val nextCursor: String? = null, + val hasMore: Boolean = false, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt new file mode 100644 index 0000000..40a89ef --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MeDto( + val uid: String, + val companyId: String, + val companyName: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String? = null, + val roles: List = emptyList(), + val branchIds: List = emptyList(), +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt b/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt new file mode 100644 index 0000000..82f77a2 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.network.interceptor + +import app.worktrack.core.network.auth.AuthTokenProvider +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Attaches the Firebase ID token plus client metadata headers. runBlocking is + * safe here: OkHttp interceptors always execute on OkHttp's dispatcher threads, + * and the Firebase SDK serves cached tokens without I/O in the common case. + */ +@Singleton +class AuthInterceptor @Inject constructor( + private val tokenProvider: AuthTokenProvider, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val token = runBlocking { tokenProvider.idToken() } + + val request = original.newBuilder() + .apply { token?.let { header("Authorization", "Bearer $it") } } + .header("X-Client", "worktrack-android") + .build() + + val response = chain.proceed(request) + + // One retry with a force-refreshed token covers expiry races. + if (response.code == 401 && token != null) { + val refreshed = runBlocking { tokenProvider.idToken(forceRefresh = true) } + if (refreshed != null && refreshed != token) { + response.close() + return chain.proceed( + original.newBuilder() + .header("Authorization", "Bearer $refreshed") + .header("X-Client", "worktrack-android") + .build(), + ) + } + } + return response + } +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt b/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt new file mode 100644 index 0000000..d601ed4 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt @@ -0,0 +1,34 @@ +package app.worktrack.core.network.serializer + +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** Wire format: ISO-8601 UTC instant, e.g. 2026-07-17T08:30:00Z. */ +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeString(value.toString()) + + override fun deserialize(decoder: Decoder): Instant = + Instant.parse(decoder.decodeString()) +} + +/** Wire format: ISO-8601 calendar date, e.g. 2026-07-17. */ +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.LocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDate) = + encoder.encodeString(value.toString()) + + override fun deserialize(decoder: Decoder): LocalDate = + LocalDate.parse(decoder.decodeString()) +} diff --git a/core/sync/build.gradle.kts b/core/sync/build.gradle.kts new file mode 100644 index 0000000..a2bf375 --- /dev/null +++ b/core/sync/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) +} + +android { + namespace = "app.worktrack.core.sync" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.data) + + implementation(libs.androidx.work.runtime) + implementation(libs.hilt.ext.work) + ksp(libs.hilt.ext.compiler) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt new file mode 100644 index 0000000..7731754 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt @@ -0,0 +1,41 @@ +package app.worktrack.core.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.isRetryable +import app.worktrack.core.domain.repository.SyncRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Executes one sync cycle (outbox push + delta pull). WorkManager provides the + * network constraint, exponential backoff, and process-death survival; the + * engine itself is idempotent, so overlapping schedules are harmless. + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncRepository: SyncRepository, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = when (val result = syncRepository.syncNow()) { + is AppResult.Success -> Result.success() + is AppResult.Failure -> + if (result.error.isRetryable && runAttemptCount < MAX_RETRIES) { + Result.retry() + } else { + // Terminal for this run; the periodic schedule (or the next + // user action) picks it up again. Failed ops stay visible in + // the outbox and Profile sync status. + Result.failure() + } + } + + companion object { + const val MAX_RETRIES = 5 + } +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt new file mode 100644 index 0000000..ebcc6f8 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt @@ -0,0 +1,64 @@ +package app.worktrack.core.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import app.worktrack.core.domain.repository.SyncScheduler +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Duration +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WorkManagerSyncScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) : SyncScheduler { + + private val workManager: WorkManager get() = WorkManager.getInstance(context) + + private val networkConstraint = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + override fun schedulePeriodicSync() { + val request = PeriodicWorkRequestBuilder(PERIODIC_INTERVAL) + .setConstraints(networkConstraint) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_INITIAL) + .build() + // KEEP: re-registering on every app start must not reset the period. + workManager.enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + override fun requestImmediateSync() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraint) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_INITIAL) + .build() + // APPEND_OR_REPLACE: a punch during an active sync queues one follow-up + // run instead of cancelling the in-flight cycle. + workManager.enqueueUniqueWork( + IMMEDIATE_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, + ) + } + + private companion object { + const val PERIODIC_WORK_NAME = "worktrack.sync.periodic" + const val IMMEDIATE_WORK_NAME = "worktrack.sync.immediate" + val PERIODIC_INTERVAL: Duration = Duration.ofMinutes(30) + val BACKOFF_INITIAL: Duration = Duration.ofSeconds(30) + } +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt new file mode 100644 index 0000000..2fc56d4 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.sync.di + +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.sync.WorkManagerSyncScheduler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SyncModule { + + @Binds + fun bindSyncScheduler(impl: WorkManagerSyncScheduler): SyncScheduler +} From 64ff4fdea03b492357c132952aa234a92e551ad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:49:04 +0000 Subject: [PATCH 004/139] feat(app): feature modules and application shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feature:auth — email/password sign-in with field validation - feature:dashboard — today's attendance card, shift, leave balances, announcements - feature:attendance — GPS punch flow with geofence status and mock-location rejection, kiosk QR scanner (CameraX + ML Kit), monthly attendance history - feature:leave — balances/requests overview, apply flow with half-days and date pickers, approver inbox with reject-note dialog - feature:payslips — yearly list and earnings/deductions detail - feature:profile — roles, sync health, manual sync, sign out - app — Hilt application with WorkManager integration, session-driven root navigation (auth vs main), Material 3 bottom navigation, deep links, backup exclusion rules, adaptive launcher icon Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- app/build.gradle.kts | 73 +++++ app/proguard-rules.pro | 20 ++ app/src/main/AndroidManifest.xml | 60 ++++ .../main/kotlin/app/worktrack/MainActivity.kt | 23 ++ .../app/worktrack/WorkTrackApplication.kt | 46 +++ .../main/kotlin/app/worktrack/di/AppModule.kt | 28 ++ .../kotlin/app/worktrack/ui/MainScaffold.kt | 114 ++++++++ .../kotlin/app/worktrack/ui/MainViewModel.kt | 35 +++ .../kotlin/app/worktrack/ui/WorkTrackApp.kt | 35 +++ .../res/drawable/ic_launcher_foreground.xml | 20 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 7 + app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 13 + feature/attendance/build.gradle.kts | 18 ++ .../attendance/di/AttendanceFeatureModule.kt | 20 ++ .../history/AttendanceHistoryScreen.kt | 155 +++++++++++ .../history/AttendanceHistoryViewModel.kt | 74 +++++ .../attendance/location/LocationClient.kt | 59 ++++ .../navigation/AttendanceNavigation.kt | 39 +++ .../feature/attendance/punch/PunchScreen.kt | 199 +++++++++++++ .../attendance/punch/PunchViewModel.kt | 173 ++++++++++++ .../feature/attendance/qr/QrScanScreen.kt | 169 ++++++++++++ feature/auth/build.gradle.kts | 7 + .../app/worktrack/feature/auth/LoginScreen.kt | 131 +++++++++ .../worktrack/feature/auth/LoginViewModel.kt | 68 +++++ .../feature/auth/navigation/AuthNavigation.kt | 17 ++ feature/dashboard/build.gradle.kts | 7 + .../feature/dashboard/DashboardScreen.kt | 231 ++++++++++++++++ .../feature/dashboard/DashboardViewModel.kt | 38 +++ .../navigation/DashboardNavigation.kt | 19 ++ feature/leave/build.gradle.kts | 7 + .../feature/leave/apply/ApplyLeaveScreen.kt | 230 +++++++++++++++ .../leave/apply/ApplyLeaveViewModel.kt | 132 +++++++++ .../leave/approvals/ApprovalsScreen.kt | 181 ++++++++++++ .../leave/approvals/ApprovalsViewModel.kt | 53 ++++ .../leave/navigation/LeaveNavigation.kt | 33 +++ .../leave/overview/LeaveOverviewScreen.kt | 261 ++++++++++++++++++ .../leave/overview/LeaveOverviewViewModel.kt | 58 ++++ feature/payslips/build.gradle.kts | 7 + .../feature/payslips/PayslipsScreen.kt | 112 ++++++++ .../feature/payslips/PayslipsViewModel.kt | 70 +++++ .../payslips/detail/PayslipDetailScreen.kt | 141 ++++++++++ .../payslips/detail/PayslipDetailViewModel.kt | 30 ++ .../payslips/navigation/PayslipsNavigation.kt | 38 +++ feature/profile/build.gradle.kts | 7 + .../feature/profile/ProfileScreen.kt | 183 ++++++++++++ .../feature/profile/ProfileViewModel.kt | 57 ++++ .../profile/navigation/ProfileNavigation.kt | 13 + 52 files changed, 3537 insertions(+) create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/app/worktrack/MainActivity.kt create mode 100644 app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt create mode 100644 app/src/main/kotlin/app/worktrack/di/AppModule.kt create mode 100644 app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt create mode 100644 app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt create mode 100644 app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 feature/attendance/build.gradle.kts create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt create mode 100644 feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt create mode 100644 feature/auth/build.gradle.kts create mode 100644 feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt create mode 100644 feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt create mode 100644 feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt create mode 100644 feature/dashboard/build.gradle.kts create mode 100644 feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt create mode 100644 feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt create mode 100644 feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt create mode 100644 feature/leave/build.gradle.kts create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt create mode 100644 feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt create mode 100644 feature/payslips/build.gradle.kts create mode 100644 feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt create mode 100644 feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt create mode 100644 feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt create mode 100644 feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt create mode 100644 feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt create mode 100644 feature/profile/build.gradle.kts create mode 100644 feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt create mode 100644 feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt create mode 100644 feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..cd52a19 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + alias(libs.plugins.worktrack.android.application) + alias(libs.plugins.worktrack.android.hilt) +} + +android { + namespace = "app.worktrack" + + defaultConfig { + applicationId = "app.worktrack" + versionCode = 1 + versionName = "1.0.0" + + // Per-environment API endpoints are configured through build types below. + buildConfigField("String", "API_BASE_URL", "\"https://api.worktrack.app/v1/\"") + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + buildConfigField( + "String", + "API_BASE_URL", + // Firebase emulator suite / local functions host from an emulator. + "\"http://10.0.2.2:5001/worktrack-dev/us-central1/api/v1/\"", + ) + } + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(projects.feature.auth) + implementation(projects.feature.dashboard) + implementation(projects.feature.attendance) + implementation(projects.feature.leave) + implementation(projects.feature.payslips) + implementation(projects.feature.profile) + + implementation(projects.core.common) + implementation(projects.core.model) + implementation(projects.core.domain) + implementation(projects.core.data) + implementation(projects.core.sync) + implementation(projects.core.network) + implementation(projects.core.designsystem) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.compose.material.icons) + + implementation(libs.androidx.work.runtime) + implementation(libs.hilt.ext.work) + ksp(libs.hilt.ext.compiler) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) +} + +// google-services.json is environment-specific and never committed; the plugin +// is applied only when the file is present so CI and fresh clones still build. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..88bfdcd --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,20 @@ +# kotlinx.serialization: keep generated serializer lookups. +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } +-keep,includedescriptorclasses class app.worktrack.**$$serializer { *; } +-keepclassmembers class app.worktrack.** { *** Companion; } +-keepclasseswithmembers class app.worktrack.** { kotlinx.serialization.KSerializer serializer(...); } + +# Retrofit reflects on interface method generics. +-keepattributes Signature, Exceptions +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# OkHttp platform hooks (harmless on Android). +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..834f9c8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/app/worktrack/MainActivity.kt b/app/src/main/kotlin/app/worktrack/MainActivity.kt new file mode 100644 index 0000000..1ab47da --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/MainActivity.kt @@ -0,0 +1,23 @@ +package app.worktrack + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import app.worktrack.core.designsystem.theme.WorkTrackTheme +import app.worktrack.ui.WorkTrackApp +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + WorkTrackTheme { + WorkTrackApp() + } + } + } +} diff --git a/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt new file mode 100644 index 0000000..9e3d374 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt @@ -0,0 +1,46 @@ +package app.worktrack + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import app.worktrack.core.common.coroutines.ApplicationScope +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@HiltAndroidApp +class WorkTrackApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + @Inject lateinit var syncScheduler: SyncScheduler + + @Inject lateinit var observeSession: ObserveSessionUseCase + + @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + // Whenever a session exists (fresh sign-in or app restart), make sure the + // periodic background sync is registered and kick one cycle immediately. + observeSession() + .filterNotNull() + .distinctUntilChangedBy { it.uid } + .onEach { + syncScheduler.schedulePeriodicSync() + syncScheduler.requestImmediateSync() + } + .launchIn(applicationScope) + } +} diff --git a/app/src/main/kotlin/app/worktrack/di/AppModule.kt b/app/src/main/kotlin/app/worktrack/di/AppModule.kt new file mode 100644 index 0000000..6b1a23f --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/di/AppModule.kt @@ -0,0 +1,28 @@ +package app.worktrack.di + +import app.worktrack.BuildConfig +import app.worktrack.core.common.coroutines.ApplicationScope +import app.worktrack.core.network.di.ApiConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideApiConfig(): ApiConfig = ApiConfig(baseUrl = BuildConfig.API_BASE_URL) + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt new file mode 100644 index 0000000..e3e60fc --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt @@ -0,0 +1,114 @@ +package app.worktrack.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BeachAccess +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.BeachAccess +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import app.worktrack.feature.attendance.navigation.PUNCH_ROUTE +import app.worktrack.feature.attendance.navigation.ATTENDANCE_HISTORY_ROUTE +import app.worktrack.feature.attendance.navigation.attendanceScreens +import app.worktrack.feature.dashboard.navigation.DASHBOARD_ROUTE +import app.worktrack.feature.dashboard.navigation.dashboardScreen +import app.worktrack.feature.leave.navigation.LEAVE_ROUTE +import app.worktrack.feature.leave.navigation.leaveScreens +import app.worktrack.feature.payslips.navigation.PAYSLIPS_ROUTE +import app.worktrack.feature.payslips.navigation.payslipScreens +import app.worktrack.feature.profile.navigation.PROFILE_ROUTE +import app.worktrack.feature.profile.navigation.profileScreen + +private data class TopLevelDestination( + val route: String, + val label: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) + +private val topLevelDestinations = listOf( + TopLevelDestination(DASHBOARD_ROUTE, "Home", Icons.Filled.Home, Icons.Outlined.Home), + TopLevelDestination(PUNCH_ROUTE, "Attendance", Icons.Filled.Fingerprint, Icons.Outlined.Fingerprint), + TopLevelDestination(LEAVE_ROUTE, "Leave", Icons.Filled.BeachAccess, Icons.Outlined.BeachAccess), + TopLevelDestination(PROFILE_ROUTE, "Profile", Icons.Filled.Person, Icons.Outlined.Person), +) + +@Composable +fun MainScaffold() { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + + val showBottomBar = currentDestination?.route in topLevelDestinations.map { it.route } + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar { + topLevelDestinations.forEach { destination -> + val selected = currentDestination + ?.hierarchy + ?.any { it.route == destination.route } == true + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = if (selected) { + destination.selectedIcon + } else { + destination.unselectedIcon + }, + contentDescription = destination.label, + ) + }, + label = { Text(destination.label) }, + ) + } + } + } + }, + ) { padding -> + NavHost( + navController = navController, + startDestination = DASHBOARD_ROUTE, + modifier = Modifier.padding(padding), + ) { + dashboardScreen( + onPunchClick = { navController.navigate(PUNCH_ROUTE) }, + onAttendanceHistoryClick = { navController.navigate(ATTENDANCE_HISTORY_ROUTE) }, + ) + attendanceScreens(navController) + leaveScreens(navController) + payslipScreens(navController) + profileScreen( + onPayslipsClick = { navController.navigate(PAYSLIPS_ROUTE) }, + ) + } + } +} diff --git a/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt b/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt new file mode 100644 index 0000000..7d2b5ed --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt @@ -0,0 +1,35 @@ +package app.worktrack.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.model.UserSession +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Root auth state: Loading until the persisted session has been read once. */ +sealed interface RootUiState { + data object Loading : RootUiState + data object SignedOut : RootUiState + data class SignedIn(val session: UserSession) : RootUiState +} + +@HiltViewModel +class MainViewModel @Inject constructor( + observeSession: ObserveSessionUseCase, +) : ViewModel() { + + val uiState: StateFlow = observeSession() + .map { session -> + if (session == null) RootUiState.SignedOut else RootUiState.SignedIn(session) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RootUiState.Loading, + ) +} diff --git a/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt b/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt new file mode 100644 index 0000000..fe146a9 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt @@ -0,0 +1,35 @@ +package app.worktrack.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.feature.auth.navigation.AUTH_GRAPH_ROUTE +import app.worktrack.feature.auth.navigation.authGraph + +/** + * Root switch between the auth and main experiences. Each state owns its own + * NavHost, so signing out atomically drops the entire main back stack (no + * stale tenant data can be navigated back to). + */ +@Composable +fun WorkTrackApp(viewModel: MainViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when (state) { + RootUiState.Loading -> FullScreenLoading(Modifier) + + RootUiState.SignedOut -> { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = AUTH_GRAPH_ROUTE) { + authGraph() + } + } + + is RootUiState.SignedIn -> MainScaffold() + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1d8db23 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3407532 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #006874 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8fed6fe --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + WorkTrack + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2e3b4cb --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..3b30a4b --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..032ca23 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/feature/attendance/build.gradle.kts b/feature/attendance/build.gradle.kts new file mode 100644 index 0000000..9631cb4 --- /dev/null +++ b/feature/attendance/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.attendance" +} + +dependencies { + implementation(libs.play.services.location) + implementation(libs.kotlinx.coroutines.play.services) + + implementation(libs.camerax.core) + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + implementation(libs.mlkit.barcode.scanning) +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt new file mode 100644 index 0000000..14c7ee3 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt @@ -0,0 +1,20 @@ +package app.worktrack.feature.attendance.di + +import android.content.Context +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object AttendanceFeatureModule { + + @Provides + fun provideFusedLocationClient( + @ApplicationContext context: Context, + ): FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt new file mode 100644 index 0000000..80df276 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt @@ -0,0 +1,155 @@ +package app.worktrack.feature.attendance.history + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.EventBusy +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendanceDayStatus +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun AttendanceHistoryRoute( + onBack: () -> Unit, + viewModel: AttendanceHistoryViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { WtTopBar(title = "Attendance history", onBack = onBack) }, + ) { padding -> + Column(Modifier.padding(padding)) { + MonthSelector( + label = "${ + state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + } ${state.month.year}", + canGoForward = state.canGoForward, + onPrevious = viewModel::onPreviousMonth, + onNext = viewModel::onNextMonth, + ) + if (state.days.isEmpty()) { + EmptyState( + icon = Icons.Filled.EventBusy, + title = "No records", + message = "Attendance for this month appears here after your first sync.", + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.days, key = { it.id }) { day -> DayCard(day) } + } + } + } + } +} + +@Composable +private fun MonthSelector( + label: String, + canGoForward: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous month") + } + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + IconButton(onClick = onNext, enabled = canGoForward) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next month") + } + } +} + +@Composable +private fun DayCard(day: AttendanceDay) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = day.date.format(DateTimeFormatter.ofPattern("EEE, d MMM")), + style = MaterialTheme.typography.titleSmall, + ) + if (day.workedMinutes > 0) { + Text( + text = "Worked ${day.workedMinutes / 60}h ${day.workedMinutes % 60}m" + + if (day.overtimeMinutes > 0) " · OT ${day.overtimeMinutes}m" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (day.lateMinutes > 0) { + Text( + text = "Late by ${day.lateMinutes}m", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + StatusChip(text = day.status.label(), tone = day.status.tone()) + } + } +} + +private fun AttendanceDayStatus.label(): String = when (this) { + AttendanceDayStatus.PRESENT -> "Present" + AttendanceDayStatus.ABSENT -> "Absent" + AttendanceDayStatus.HALF_DAY -> "Half day" + AttendanceDayStatus.LEAVE -> "Leave" + AttendanceDayStatus.HOLIDAY -> "Holiday" + AttendanceDayStatus.WEEK_OFF -> "Week off" + AttendanceDayStatus.PENDING -> "Pending" +} + +private fun AttendanceDayStatus.tone(): ChipTone = when (this) { + AttendanceDayStatus.PRESENT -> ChipTone.POSITIVE + AttendanceDayStatus.ABSENT -> ChipTone.NEGATIVE + AttendanceDayStatus.HALF_DAY, AttendanceDayStatus.PENDING -> ChipTone.WARNING + else -> ChipTone.NEUTRAL +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt new file mode 100644 index 0000000..5dc4677 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt @@ -0,0 +1,74 @@ +package app.worktrack.feature.attendance.history + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.usecase.attendance.ObserveAttendanceHistoryUseCase +import app.worktrack.core.model.AttendanceDay +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.YearMonth +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +data class AttendanceHistoryUiState( + val month: YearMonth, + val days: List, + val canGoForward: Boolean, +) + +@HiltViewModel +class AttendanceHistoryViewModel @Inject constructor( + observeHistory: ObserveAttendanceHistoryUseCase, + private val timeProvider: TimeProvider, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + // Persist the selected month across process death (survives low-memory kills). + private val month: StateFlow = savedStateHandle.getStateFlow( + KEY_MONTH, + YearMonth.from(timeProvider.today()).toString(), + ) + + val uiState: StateFlow = month + .map(YearMonth::parse) + .flatMapLatest { selected -> + observeHistory(selected).map { days -> selected to days } + } + .combine(month) { (selected, days), _ -> + AttendanceHistoryUiState( + month = selected, + days = days, + canGoForward = selected < YearMonth.from(timeProvider.today()), + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AttendanceHistoryUiState( + month = YearMonth.parse(month.value), + days = emptyList(), + canGoForward = false, + ), + ) + + fun onPreviousMonth() = shiftMonth(-1) + + fun onNextMonth() = shiftMonth(+1) + + private fun shiftMonth(delta: Long) { + val current = YearMonth.parse(month.value) + val target = current.plusMonths(delta) + if (target > YearMonth.from(timeProvider.today())) return + savedStateHandle[KEY_MONTH] = target.toString() + } + + private companion object { + const val KEY_MONTH = "month" + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt new file mode 100644 index 0000000..c1fa5d1 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt @@ -0,0 +1,59 @@ +package app.worktrack.feature.attendance.location + +import android.Manifest +import android.annotation.SuppressLint +import android.os.Build +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import javax.inject.Inject +import kotlinx.coroutines.tasks.await + +data class DeviceLocation( + val latitude: Double, + val longitude: Double, + val accuracyMeters: Float, + val isMock: Boolean, +) + +/** + * One-shot high-accuracy location fix for punching. Callers must hold + * ACCESS_FINE_LOCATION before invoking; the screen gates on the permission. + */ +class LocationClient @Inject constructor( + private val fusedClient: FusedLocationProviderClient, +) { + + @SuppressLint("MissingPermission") + @androidx.annotation.RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + suspend fun currentLocation(): DeviceLocation? { + val request = CurrentLocationRequest.Builder() + .setPriority(Priority.PRIORITY_HIGH_ACCURACY) + .setDurationMillis(TIMEOUT_MILLIS) + .setMaxUpdateAgeMillis(MAX_AGE_MILLIS) + .build() + + val location = fusedClient + .getCurrentLocation(request, CancellationTokenSource().token) + .await() ?: return null + + val isMock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + location.isMock + } else { + @Suppress("DEPRECATION") + location.isFromMockProvider + } + return DeviceLocation( + latitude = location.latitude, + longitude = location.longitude, + accuracyMeters = location.accuracy, + isMock = isMock, + ) + } + + private companion object { + const val TIMEOUT_MILLIS = 15_000L + const val MAX_AGE_MILLIS = 10_000L // a stale fix is worse than a short wait + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt new file mode 100644 index 0000000..797e850 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt @@ -0,0 +1,39 @@ +package app.worktrack.feature.attendance.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.attendance.history.AttendanceHistoryRoute +import app.worktrack.feature.attendance.punch.PunchRoute +import app.worktrack.feature.attendance.punch.PunchViewModel +import app.worktrack.feature.attendance.qr.QrScanRoute + +const val PUNCH_ROUTE = "attendance/punch" +const val QR_SCAN_ROUTE = "attendance/qr-scan" +const val ATTENDANCE_HISTORY_ROUTE = "attendance/history" + +fun NavGraphBuilder.attendanceScreens(navController: NavController) { + composable(route = PUNCH_ROUTE) { + PunchRoute( + onBack = { navController.popBackStack() }, + onScanQr = { navController.navigate(QR_SCAN_ROUTE) }, + ) + } + + composable(route = QR_SCAN_ROUTE) { + QrScanRoute( + onBack = { navController.popBackStack() }, + onTokenScanned = { token -> + // Hand the token to the punch screen's SavedStateHandle and pop. + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PunchViewModel.KEY_KIOSK_TOKEN, token) + navController.popBackStack() + }, + ) + } + + composable(route = ATTENDANCE_HISTORY_ROUTE) { + AttendanceHistoryRoute(onBack = { navController.popBackStack() }) + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt new file mode 100644 index 0000000..794b4e3 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt @@ -0,0 +1,199 @@ +package app.worktrack.feature.attendance.punch + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.model.PunchType + +@Composable +fun PunchRoute( + onBack: () -> Unit, + onScanQr: () -> Unit, + viewModel: PunchViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val today by viewModel.today.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) viewModel.onLocationPermissionGranted() else viewModel.onLocationPermissionDenied() + } + + LaunchedEffect(Unit) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + viewModel.onLocationPermissionGranted() + } else { + permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is PunchEffect.Message -> snackbarHostState.showSnackbar(effect.text) + is PunchEffect.PunchRecorded -> snackbarHostState.showSnackbar( + if (effect.type == PunchType.IN) { + "Clocked in — will sync automatically" + } else { + "Clocked out — will sync automatically" + }, + ) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = "Attendance punch", onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + PunchScreen( + state = state, + clockedIn = today?.clockedIn == true, + onPunch = viewModel::onPunch, + onRetryLocation = { + permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + }, + onScanQr = onScanQr, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +internal fun PunchScreen( + state: PunchUiState, + clockedIn: Boolean, + onPunch: () -> Unit, + onRetryLocation: () -> Unit, + onScanQr: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LocationStatusCard(state.location, onRetryLocation) + + Spacer(Modifier.height(32.dp)) + + WtPrimaryButton( + text = if (clockedIn) "Clock out" else "Clock in", + onClick = onPunch, + modifier = Modifier.fillMaxWidth(), + enabled = state.location is LocationUiState.Ready, + loading = state.isPunching, + ) + + Spacer(Modifier.height(16.dp)) + + WtSecondaryButton( + text = "Scan kiosk QR instead", + onClick = onScanQr, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun LocationStatusCard( + location: LocationUiState, + onRetry: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (location) { + LocationUiState.PermissionRequired -> { + Text("Location permission needed", style = MaterialTheme.typography.titleMedium) + } + + LocationUiState.Acquiring -> { + Text("Getting your location…", style = MaterialTheme.typography.titleMedium) + } + + is LocationUiState.Ready -> { + val evaluation = location.evaluation + when { + !evaluation.fencesConfigured -> StatusChip("No geofence required", ChipTone.NEUTRAL) + evaluation.insideFence -> StatusChip( + "Inside ${evaluation.nearestFence?.name ?: "work area"}", + ChipTone.POSITIVE, + ) + + else -> StatusChip( + "Outside work area (${evaluation.distanceMeters?.toInt() ?: "?"} m away)", + ChipTone.NEGATIVE, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = "Accuracy ±${location.location.accuracyMeters.toInt()} m", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + is LocationUiState.Unavailable -> { + Text( + text = location.reason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + WtSecondaryButton(text = "Retry", onClick = onRetry) + } + } + Spacer(Modifier.height(8.dp)) + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt new file mode 100644 index 0000000..0a539c5 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt @@ -0,0 +1,173 @@ +package app.worktrack.feature.attendance.punch + +import android.Manifest +import androidx.annotation.RequiresPermission +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.userMessage +import app.worktrack.core.domain.usecase.attendance.EvaluateGeofenceUseCase +import app.worktrack.core.domain.usecase.attendance.GeofenceEvaluation +import app.worktrack.core.domain.usecase.attendance.ObserveTodayAttendanceUseCase +import app.worktrack.core.domain.usecase.attendance.PunchClockUseCase +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.TodayAttendance +import app.worktrack.feature.attendance.location.DeviceLocation +import app.worktrack.feature.attendance.location.LocationClient +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface LocationUiState { + data object PermissionRequired : LocationUiState + data object Acquiring : LocationUiState + data class Ready( + val location: DeviceLocation, + val evaluation: GeofenceEvaluation, + ) : LocationUiState + + data class Unavailable(val reason: String) : LocationUiState +} + +data class PunchUiState( + val location: LocationUiState = LocationUiState.PermissionRequired, + val isPunching: Boolean = false, +) + +sealed interface PunchEffect { + data class Message(val text: String) : PunchEffect + data class PunchRecorded(val type: PunchType) : PunchEffect +} + +@HiltViewModel +class PunchViewModel @Inject constructor( + observeToday: ObserveTodayAttendanceUseCase, + private val punchClock: PunchClockUseCase, + private val evaluateGeofence: EvaluateGeofenceUseCase, + private val locationClient: LocationClient, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val today: StateFlow = observeToday() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + private val _uiState = MutableStateFlow(PunchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + init { + // A kiosk token arrives via SavedStateHandle when the QR scanner pops back. + viewModelScope.launch { + savedStateHandle.getStateFlow(KEY_KIOSK_TOKEN, null).collect { token -> + if (!token.isNullOrBlank()) { + savedStateHandle[KEY_KIOSK_TOKEN] = null + punchWithQr(token) + } + } + } + } + + /** Invoked by the screen once ACCESS_FINE_LOCATION is granted. */ + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + fun onLocationPermissionGranted() { + if (_uiState.value.location is LocationUiState.Acquiring) return + _uiState.update { it.copy(location = LocationUiState.Acquiring) } + + viewModelScope.launch { + val location = try { + locationClient.currentLocation() + } catch (_: SecurityException) { + null + } + if (location == null) { + _uiState.update { + it.copy( + location = LocationUiState.Unavailable( + "Couldn't get a GPS fix. Move somewhere with a clearer view of the sky and retry.", + ), + ) + } + } else { + val evaluation = + evaluateGeofence(location.latitude, location.longitude, location.accuracyMeters) + _uiState.update { it.copy(location = LocationUiState.Ready(location, evaluation)) } + } + } + } + + fun onLocationPermissionDenied() { + _uiState.update { + it.copy( + location = LocationUiState.Unavailable( + "Location permission is required for GPS punch. Use kiosk QR instead.", + ), + ) + } + } + + fun onPunch() { + val ready = _uiState.value.location as? LocationUiState.Ready ?: return + val nextType = nextPunchType() ?: return + submit( + PunchCommand( + type = nextType, + method = PunchMethod.GPS, + latitude = ready.location.latitude, + longitude = ready.location.longitude, + accuracyMeters = ready.location.accuracyMeters, + isMockLocation = ready.location.isMock, + ), + ) + } + + private fun punchWithQr(kioskToken: String) { + val nextType = nextPunchType() ?: return + val ready = _uiState.value.location as? LocationUiState.Ready + submit( + PunchCommand( + type = nextType, + method = PunchMethod.QR, + latitude = ready?.location?.latitude, + longitude = ready?.location?.longitude, + accuracyMeters = ready?.location?.accuracyMeters, + isMockLocation = ready?.location?.isMock ?: false, + kioskToken = kioskToken, + ), + ) + } + + private fun submit(command: PunchCommand) { + if (_uiState.value.isPunching) return + _uiState.update { it.copy(isPunching = true) } + viewModelScope.launch { + when (val result = punchClock(command)) { + is AppResult.Success -> + _effects.send(PunchEffect.PunchRecorded(command.type)) + + is AppResult.Failure -> + _effects.send(PunchEffect.Message(result.error.userMessage())) + } + _uiState.update { it.copy(isPunching = false) } + } + } + + private fun nextPunchType(): PunchType? = + today.value?.let { if (it.clockedIn) PunchType.OUT else PunchType.IN } + + companion object { + const val KEY_KIOSK_TOKEN = "kioskToken" + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt new file mode 100644 index 0000000..8e2e50e --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt @@ -0,0 +1,169 @@ +package app.worktrack.feature.attendance.qr + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtTopBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NoPhotography +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Full-screen kiosk QR scanner. Fires [onTokenScanned] exactly once with the + * raw QR payload (the signed kiosk TOTP token) and expects the caller to pop. + */ +@Composable +fun QrScanRoute( + onBack: () -> Unit, + onTokenScanned: (String) -> Unit, +) { + val context = LocalContext.current + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> hasCameraPermission = granted } + + androidx.compose.runtime.LaunchedEffect(Unit) { + if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) + } + + Scaffold( + topBar = { WtTopBar(title = "Scan kiosk QR", onBack = onBack) }, + ) { padding -> + if (hasCameraPermission) { + CameraQrScanner( + onTokenScanned = onTokenScanned, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } else { + EmptyState( + icon = Icons.Filled.NoPhotography, + title = "Camera permission needed", + message = "Allow camera access to scan the kiosk QR code.", + modifier = Modifier.padding(padding), + ) + } + } +} + +@Composable +private fun CameraQrScanner( + onTokenScanned: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + val scanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), + ) + } + // Guards against multiple fires while the pop-back animation runs. + val delivered = remember { AtomicBoolean(false) } + + DisposableEffect(Unit) { + onDispose { + scanner.close() + analysisExecutor.shutdown() + ProcessCameraProvider.getInstance(context).get().unbindAll() + } + } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + val previewView = PreviewView(viewContext) + val providerFuture = ProcessCameraProvider.getInstance(viewContext) + providerFuture.addListener( + { + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(analysisExecutor) { imageProxy -> + processFrame(imageProxy, scanner, delivered, onTokenScanned) + } + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + }, + ContextCompat.getMainExecutor(viewContext), + ) + previewView + }, + ) + Text( + text = "Point the camera at the kiosk screen", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) +} + +private fun processFrame( + imageProxy: ImageProxy, + scanner: com.google.mlkit.vision.barcode.BarcodeScanner, + delivered: AtomicBoolean, + onTokenScanned: (String) -> Unit, +) { + val mediaImage = imageProxy.image + if (mediaImage == null || delivered.get()) { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val token = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (token != null && delivered.compareAndSet(false, true)) { + onTokenScanned(token) + } + } + .addOnCompleteListener { imageProxy.close() } +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts new file mode 100644 index 0000000..234ea24 --- /dev/null +++ b/feature/auth/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.auth" +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt new file mode 100644 index 0000000..7a1d129 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt @@ -0,0 +1,131 @@ +package app.worktrack.feature.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTextField + +@Composable +fun LoginRoute(viewModel: LoginViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LoginScreen( + state = state, + onEmailChange = viewModel::onEmailChange, + onPasswordChange = viewModel::onPasswordChange, + onTogglePasswordVisibility = viewModel::onTogglePasswordVisibility, + onSubmit = viewModel::onSubmit, + ) +} + +@Composable +internal fun LoginScreen( + state: LoginUiState, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onTogglePasswordVisibility: () -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "WorkTrack", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Smart workforce management", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(40.dp)) + + WtTextField( + value = state.email, + onValueChange = onEmailChange, + label = "Work email", + modifier = Modifier.fillMaxWidth(), + errorText = state.fieldErrors["email"], + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + ) + Spacer(Modifier.height(16.dp)) + WtTextField( + value = state.password, + onValueChange = onPasswordChange, + label = "Password", + modifier = Modifier.fillMaxWidth(), + errorText = state.fieldErrors["password"], + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (state.passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = onTogglePasswordVisibility) { + Icon( + imageVector = if (state.passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = if (state.passwordVisible) { + "Hide password" + } else { + "Show password" + }, + ) + } + }, + ) + + state.errorMessage?.let { message -> + Spacer(Modifier.height(12.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = "Sign in", + onClick = onSubmit, + modifier = Modifier.fillMaxWidth(), + loading = state.isSubmitting, + ) + } +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt new file mode 100644 index 0000000..ca4f0c4 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt @@ -0,0 +1,68 @@ +package app.worktrack.feature.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.userMessage +import app.worktrack.core.domain.usecase.auth.SignInUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class LoginUiState( + val email: String = "", + val password: String = "", + val passwordVisible: Boolean = false, + val isSubmitting: Boolean = false, + val fieldErrors: Map = emptyMap(), + val errorMessage: String? = null, +) + +/** + * Sign-in flow. Successful sign-in persists the session; the root nav host + * observes the session and switches to the main graph — no nav event needed. + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val signIn: SignInUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEmailChange(value: String) { + _uiState.update { it.copy(email = value, fieldErrors = it.fieldErrors - "email", errorMessage = null) } + } + + fun onPasswordChange(value: String) { + _uiState.update { it.copy(password = value, fieldErrors = it.fieldErrors - "password", errorMessage = null) } + } + + fun onTogglePasswordVisibility() { + _uiState.update { it.copy(passwordVisible = !it.passwordVisible) } + } + + fun onSubmit() { + val state = _uiState.value + if (state.isSubmitting) return + _uiState.update { it.copy(isSubmitting = true, errorMessage = null, fieldErrors = emptyMap()) } + + viewModelScope.launch { + when (val result = signIn(state.email, state.password)) { + is AppResult.Success -> _uiState.update { it.copy(isSubmitting = false) } + is AppResult.Failure -> _uiState.update { + it.copy( + isSubmitting = false, + fieldErrors = (result.error as? AppError.Validation)?.fieldErrors.orEmpty(), + errorMessage = result.error.userMessage(), + ) + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt new file mode 100644 index 0000000..e72af73 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt @@ -0,0 +1,17 @@ +package app.worktrack.feature.auth.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import app.worktrack.feature.auth.LoginRoute + +const val AUTH_GRAPH_ROUTE = "auth" +const val LOGIN_ROUTE = "auth/login" + +fun NavGraphBuilder.authGraph() { + navigation(startDestination = LOGIN_ROUTE, route = AUTH_GRAPH_ROUTE) { + composable(route = LOGIN_ROUTE) { + LoginRoute() + } + } +} diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts new file mode 100644 index 0000000..6ca9118 --- /dev/null +++ b/feature/dashboard/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.dashboard" +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..d86f6d6 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt @@ -0,0 +1,231 @@ +package app.worktrack.feature.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.domain.usecase.dashboard.DashboardSnapshot +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.AnnouncementPriority +import app.worktrack.core.model.LeaveBalance +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun DashboardRoute( + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, + viewModel: DashboardViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val s = state) { + DashboardUiState.Loading -> FullScreenLoading() + is DashboardUiState.Ready -> DashboardScreen( + snapshot = s.snapshot, + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + ) + } +} + +@Composable +internal fun DashboardScreen( + snapshot: DashboardSnapshot, + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = "Hello, ${snapshot.session.displayName.substringBefore(' ')}", + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = snapshot.session.companyName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { + TodayCard( + snapshot = snapshot, + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + ) + } + + if (snapshot.leaveBalances.isNotEmpty()) { + item { SectionHeader("Leave balances") } + item { BalancesRow(snapshot.leaveBalances) } + } + + if (snapshot.announcements.isNotEmpty()) { + item { SectionHeader("Announcements") } + items(snapshot.announcements, key = { it.id }) { announcement -> + AnnouncementCard(announcement) + } + } + + item { Spacer(Modifier.height(24.dp)) } + } +} + +@Composable +private fun TodayCard( + snapshot: DashboardSnapshot, + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, +) { + val today = snapshot.today + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + text = if (today.clockedIn) "Clocked in" else "Not clocked in", + style = MaterialTheme.typography.titleMedium, + ) + val timeFormat = DateTimeFormatter.ofPattern("HH:mm") + val zone = ZoneId.systemDefault() + today.firstInAt?.let { + Text( + text = "First in ${timeFormat.format(it.atZone(zone))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Worked ${today.workedMinutesSoFar / 60}h ${today.workedMinutesSoFar % 60}m", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusChip( + text = if (today.clockedIn) "IN" else "OUT", + tone = if (today.clockedIn) ChipTone.POSITIVE else ChipTone.NEUTRAL, + ) + } + + today.shift?.let { shift -> + Spacer(Modifier.height(8.dp)) + Text( + text = "Shift: ${shift.name} (${shift.startTime}–${shift.endTime})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(12.dp)) + Row { + WtPrimaryButton( + text = if (today.clockedIn) "Clock out" else "Clock in", + onClick = onPunchClick, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(12.dp)) + WtSecondaryButton( + text = "History", + onClick = onAttendanceHistoryClick, + ) + } + } + } +} + +@Composable +private fun BalancesRow(balances: List) { + LazyRow( + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(balances, key = { it.id }) { balance -> + Card { + Column(Modifier.padding(12.dp)) { + Text( + text = "%.1f".format(balance.availableDays), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "days available", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun AnnouncementCard(announcement: Announcement) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = announcement.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (announcement.priority != AnnouncementPriority.NORMAL) { + StatusChip( + text = announcement.priority.name, + tone = if (announcement.priority == AnnouncementPriority.URGENT) { + ChipTone.NEGATIVE + } else { + ChipTone.WARNING + }, + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = announcement.body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..943ebb8 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt @@ -0,0 +1,38 @@ +package app.worktrack.feature.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.dashboard.DashboardSnapshot +import app.worktrack.core.domain.usecase.dashboard.ObserveDashboardUseCase +import app.worktrack.core.domain.usecase.sync.TriggerSyncUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +sealed interface DashboardUiState { + data object Loading : DashboardUiState + data class Ready(val snapshot: DashboardSnapshot) : DashboardUiState +} + +@HiltViewModel +class DashboardViewModel @Inject constructor( + observeDashboard: ObserveDashboardUseCase, + private val triggerSync: TriggerSyncUseCase, +) : ViewModel() { + + val uiState: StateFlow = observeDashboard() + .map { snapshot -> + if (snapshot == null) DashboardUiState.Loading else DashboardUiState.Ready(snapshot) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DashboardUiState.Loading, + ) + + /** Pull-to-refresh: sync runs in the background; Room flows update the UI. */ + fun onRefresh() = triggerSync() +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt new file mode 100644 index 0000000..3c27d67 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt @@ -0,0 +1,19 @@ +package app.worktrack.feature.dashboard.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.dashboard.DashboardRoute + +const val DASHBOARD_ROUTE = "dashboard" + +fun NavGraphBuilder.dashboardScreen( + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, +) { + composable(route = DASHBOARD_ROUTE) { + DashboardRoute( + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + ) + } +} diff --git a/feature/leave/build.gradle.kts b/feature/leave/build.gradle.kts new file mode 100644 index 0000000..0e04d66 --- /dev/null +++ b/feature/leave/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.leave" +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt new file mode 100644 index 0000000..915f0a3 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt @@ -0,0 +1,230 @@ +package app.worktrack.feature.leave.apply + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.component.WtTopBar +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +@Composable +fun ApplyLeaveRoute( + onBack: () -> Unit, + viewModel: ApplyLeaveViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val types by viewModel.types.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + ApplyLeaveEffect.Submitted -> onBack() + is ApplyLeaveEffect.Message -> snackbarHostState.showSnackbar(effect.text) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = "Apply for leave", onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + ApplyLeaveScreen( + state = state, + typeNames = types.associate { it.id to it.name }, + onTypeSelect = viewModel::onTypeSelect, + onStartDate = viewModel::onStartDate, + onEndDate = viewModel::onEndDate, + onStartHalfDayToggle = viewModel::onStartHalfDayToggle, + onEndHalfDayToggle = viewModel::onEndHalfDayToggle, + onReasonChange = viewModel::onReasonChange, + onSubmit = viewModel::onSubmit, + modifier = Modifier.padding(padding), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ApplyLeaveScreen( + state: ApplyLeaveUiState, + typeNames: Map, + onTypeSelect: (String) -> Unit, + onStartDate: (LocalDate) -> Unit, + onEndDate: (LocalDate) -> Unit, + onStartHalfDayToggle: () -> Unit, + onEndHalfDayToggle: () -> Unit, + onReasonChange: (String) -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + var datePickerTarget by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + SectionHeader("Leave type") + Row( + Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + typeNames.forEach { (id, name) -> + FilterChip( + selected = state.leaveTypeId == id, + onClick = { onTypeSelect(id) }, + label = { Text(name) }, + ) + } + } + state.fieldErrors["leaveTypeId"]?.let { FieldError(it) } + + SectionHeader("Dates") + Row( + Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val dateFormat = DateTimeFormatter.ofPattern("d MMM yyyy") + AssistChip( + onClick = { datePickerTarget = DateTarget.START }, + label = { Text(state.startDate?.format(dateFormat) ?: "Start date") }, + ) + AssistChip( + onClick = { datePickerTarget = DateTarget.END }, + label = { Text(state.endDate?.format(dateFormat) ?: "End date") }, + ) + } + state.fieldErrors["startDate"]?.let { FieldError(it) } + state.fieldErrors["endDate"]?.let { FieldError(it) } + + Row( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.startHalfDay, + onClick = onStartHalfDayToggle, + label = { Text("Half first day") }, + ) + FilterChip( + selected = state.endHalfDay, + onClick = onEndHalfDayToggle, + label = { Text("Half last day") }, + ) + } + + if (state.estimatedDays > 0) { + Text( + text = "≈ %.1f days".format(state.estimatedDays) + + " (weekends/holidays excluded on approval)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + SectionHeader("Reason") + WtTextField( + value = state.reason, + onValueChange = onReasonChange, + label = "Why do you need this leave?", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + errorText = state.fieldErrors["reason"], + singleLine = false, + ) + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = "Submit request", + onClick = onSubmit, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + loading = state.isSubmitting, + ) + } + + datePickerTarget?.let { target -> + val initial = when (target) { + DateTarget.START -> state.startDate + DateTarget.END -> state.endDate + } ?: LocalDate.now() + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = initial.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = { datePickerTarget = null }, + confirmButton = { + TextButton( + onClick = { + pickerState.selectedDateMillis?.let { millis -> + val date = Instant.ofEpochMilli(millis) + .atZone(ZoneOffset.UTC) + .toLocalDate() + when (target) { + DateTarget.START -> onStartDate(date) + DateTarget.END -> onEndDate(date) + } + } + datePickerTarget = null + }, + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { datePickerTarget = null }) { Text("Cancel") } + }, + ) { + DatePicker(state = pickerState) + } + } +} + +private enum class DateTarget { START, END } + +@Composable +private fun FieldError(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt new file mode 100644 index 0000000..706ec6d --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt @@ -0,0 +1,132 @@ +package app.worktrack.feature.leave.apply + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.userMessage +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.usecase.leave.ApplyLeaveUseCase +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveType +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ApplyLeaveUiState( + val leaveTypeId: String? = null, + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val reason: String = "", + val isSubmitting: Boolean = false, + val fieldErrors: Map = emptyMap(), +) { + val estimatedDays: Double + get() { + val start = startDate ?: return 0.0 + val end = endDate ?: return 0.0 + if (leaveTypeId == null) return 0.0 + return ApplyLeaveUseCase.calculateDays( + LeaveApplication(leaveTypeId, start, end, startHalfDay, endHalfDay, reason), + ) + } +} + +sealed interface ApplyLeaveEffect { + data object Submitted : ApplyLeaveEffect + data class Message(val text: String) : ApplyLeaveEffect +} + +@HiltViewModel +class ApplyLeaveViewModel @Inject constructor( + leaveRepository: LeaveRepository, + private val applyLeave: ApplyLeaveUseCase, +) : ViewModel() { + + val types: StateFlow> = leaveRepository.observeTypes() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _uiState = MutableStateFlow(ApplyLeaveUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + fun onTypeSelect(typeId: String) = _uiState.update { it.copy(leaveTypeId = typeId) } + + fun onStartDate(date: LocalDate) = _uiState.update { + it.copy( + startDate = date, + // Keep the range valid as the user picks dates out of order. + endDate = it.endDate?.takeIf { end -> !end.isBefore(date) } ?: date, + fieldErrors = it.fieldErrors - "startDate", + ) + } + + fun onEndDate(date: LocalDate) = _uiState.update { + it.copy(endDate = date, fieldErrors = it.fieldErrors - "endDate") + } + + fun onStartHalfDayToggle() = _uiState.update { it.copy(startHalfDay = !it.startHalfDay) } + + fun onEndHalfDayToggle() = _uiState.update { it.copy(endHalfDay = !it.endHalfDay) } + + fun onReasonChange(value: String) = _uiState.update { + it.copy(reason = value, fieldErrors = it.fieldErrors - "reason") + } + + fun onSubmit() { + val state = _uiState.value + if (state.isSubmitting) return + + val typeId = state.leaveTypeId + val start = state.startDate + val end = state.endDate + val missing = buildMap { + if (typeId == null) put("leaveTypeId", "Choose a leave type") + if (start == null) put("startDate", "Choose a start date") + if (end == null) put("endDate", "Choose an end date") + } + if (missing.isNotEmpty() || typeId == null || start == null || end == null) { + _uiState.update { it.copy(fieldErrors = missing) } + return + } + + _uiState.update { it.copy(isSubmitting = true, fieldErrors = emptyMap()) } + viewModelScope.launch { + val result = applyLeave( + LeaveApplication( + leaveTypeId = typeId, + startDate = start, + endDate = end, + startHalfDay = state.startHalfDay, + endHalfDay = state.endHalfDay, + reason = state.reason, + ), + ) + when (result) { + is AppResult.Success -> _effects.send(ApplyLeaveEffect.Submitted) + is AppResult.Failure -> { + _uiState.update { + it.copy( + fieldErrors = (result.error as? AppError.Validation)?.fieldErrors.orEmpty(), + ) + } + _effects.send(ApplyLeaveEffect.Message(result.error.userMessage())) + } + } + _uiState.update { it.copy(isSubmitting = false) } + } + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt new file mode 100644 index 0000000..3b12d06 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt @@ -0,0 +1,181 @@ +package app.worktrack.feature.leave.approvals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveRequest +import java.time.format.DateTimeFormatter + +@Composable +fun ApprovalsRoute( + onBack: () -> Unit, + viewModel: ApprovalsViewModel = hiltViewModel(), +) { + val pending by viewModel.pending.collectAsStateWithLifecycle() + val deciding by viewModel.deciding.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var rejectTarget by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.messages.collect { snackbarHostState.showSnackbar(it) } + } + + Scaffold( + topBar = { WtTopBar(title = "Approvals", onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (pending.isEmpty()) { + EmptyState( + icon = Icons.Filled.Inbox, + title = "All caught up", + message = "No leave requests are waiting for your decision.", + modifier = Modifier.padding(padding), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(pending, key = { it.id }) { request -> + ApprovalCard( + request = request, + busy = request.id in deciding, + onApprove = { + viewModel.onDecide(request.id, ApprovalDecision.APPROVE, note = null) + }, + onReject = { rejectTarget = request }, + ) + } + } + } + } + + rejectTarget?.let { target -> + RejectDialog( + employeeName = target.employeeName ?: "this employee", + onConfirm = { note -> + viewModel.onDecide(target.id, ApprovalDecision.REJECT, note) + rejectTarget = null + }, + onDismiss = { rejectTarget = null }, + ) + } +} + +@Composable +private fun ApprovalCard( + request: LeaveRequest, + busy: Boolean, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + val dateFormat = DateTimeFormatter.ofPattern("d MMM") + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = request.employeeName ?: request.employeeId, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "${request.startDate.format(dateFormat)} – " + + "${request.endDate.format(dateFormat)} · %.1f days".format(request.days), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = request.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row { + WtPrimaryButton( + text = "Approve", + onClick = onApprove, + modifier = Modifier.weight(1f), + loading = busy, + ) + Spacer(Modifier.width(8.dp)) + WtSecondaryButton( + text = "Reject", + onClick = onReject, + modifier = Modifier.weight(1f), + enabled = !busy, + ) + } + } + } +} + +@Composable +private fun RejectDialog( + employeeName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var note by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reject request") }, + text = { + Column { + Text("Tell $employeeName why this request is being rejected.") + Spacer(Modifier.height(8.dp)) + WtTextField( + value = note, + onValueChange = { note = it }, + label = "Reason", + singleLine = false, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(note) }, + enabled = note.isNotBlank(), + ) { Text("Reject") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt new file mode 100644 index 0000000..a461852 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt @@ -0,0 +1,53 @@ +package app.worktrack.feature.leave.approvals + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.userMessage +import app.worktrack.core.domain.usecase.leave.DecideLeaveRequestUseCase +import app.worktrack.core.domain.usecase.leave.ObservePendingApprovalsUseCase +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class ApprovalsViewModel @Inject constructor( + observePendingApprovals: ObservePendingApprovalsUseCase, + private val decideRequest: DecideLeaveRequestUseCase, +) : ViewModel() { + + val pending: StateFlow> = observePendingApprovals() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Request ids with an in-flight decision, to disable their buttons. */ + private val _deciding = MutableStateFlow>(emptySet()) + val deciding: StateFlow> = _deciding.asStateFlow() + + private val _messages = Channel(Channel.BUFFERED) + val messages = _messages.receiveAsFlow() + + fun onDecide(requestId: String, decision: ApprovalDecision, note: String?) { + if (requestId in _deciding.value) return + _deciding.update { it + requestId } + viewModelScope.launch { + when (val result = decideRequest(requestId, decision, note)) { + is AppResult.Success -> _messages.send( + if (decision == ApprovalDecision.APPROVE) "Request approved" else "Request rejected", + ) + + is AppResult.Failure -> _messages.send(result.error.userMessage()) + } + _deciding.update { it - requestId } + } + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt new file mode 100644 index 0000000..7235dee --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt @@ -0,0 +1,33 @@ +package app.worktrack.feature.leave.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import app.worktrack.feature.leave.apply.ApplyLeaveRoute +import app.worktrack.feature.leave.approvals.ApprovalsRoute +import app.worktrack.feature.leave.overview.LeaveOverviewRoute + +const val LEAVE_ROUTE = "leave" +const val APPLY_LEAVE_ROUTE = "leave/apply" +const val APPROVALS_ROUTE = "leave/approvals" + +fun NavGraphBuilder.leaveScreens(navController: NavController) { + composable(route = LEAVE_ROUTE) { + LeaveOverviewRoute( + onApplyClick = { navController.navigate(APPLY_LEAVE_ROUTE) }, + onApprovalsClick = { navController.navigate(APPROVALS_ROUTE) }, + ) + } + + composable(route = APPLY_LEAVE_ROUTE) { + ApplyLeaveRoute(onBack = { navController.popBackStack() }) + } + + composable( + route = APPROVALS_ROUTE, + deepLinks = listOf(navDeepLink { uriPattern = "worktrack://approvals" }), + ) { + ApprovalsRoute(onBack = { navController.popBackStack() }) + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt new file mode 100644 index 0000000..197be55 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt @@ -0,0 +1,261 @@ +package app.worktrack.feature.leave.overview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BeachAccess +import androidx.compose.material3.Card +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.ColorDotChip +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.SyncStatus +import java.time.format.DateTimeFormatter + +@Composable +fun LeaveOverviewRoute( + onApplyClick: () -> Unit, + onApprovalsClick: () -> Unit, + viewModel: LeaveOverviewViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.messages.collect { snackbarHostState.showSnackbar(it) } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onApplyClick, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text("Apply") }, + ) + }, + ) { padding -> + LeaveOverviewScreen( + state = state, + onApprovalsClick = onApprovalsClick, + onCancelRequest = viewModel::onCancelRequest, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +internal fun LeaveOverviewScreen( + state: LeaveOverviewUiState, + onApprovalsClick: () -> Unit, + onCancelRequest: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.isApprover) { + item { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Team requests waiting for you", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onApprovalsClick) { Text("Review") } + } + } + } + } + + item { SectionHeader("Balances") } + item { + BalanceRow(balances = state.overview.balances, typeOf = { state.overview.typeOf(it) }) + } + + item { SectionHeader("My requests") } + if (state.overview.myRequests.isEmpty()) { + item { + EmptyState( + icon = Icons.Filled.BeachAccess, + title = "No leave requests yet", + message = "Tap Apply to request time off.", + modifier = Modifier.height(280.dp), + ) + } + } else { + items(state.overview.myRequests, key = { it.id }) { request -> + RequestCard( + request = request, + type = state.overview.typeOf(request.leaveTypeId), + onCancel = { onCancelRequest(request.id) }, + ) + } + } + item { Spacer(Modifier.height(80.dp)) } // clear the FAB + } +} + +@Composable +private fun BalanceRow( + balances: List, + typeOf: (String) -> LeaveType?, +) { + if (balances.isEmpty()) { + Text( + text = "Balances appear after your first sync.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + return + } + LazyRow( + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(balances, key = { it.id }) { balance -> + val type = typeOf(balance.leaveTypeId) + Card { + Column(Modifier.padding(12.dp)) { + Text( + text = "%.1f".format(balance.availableDays), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = type?.name ?: "Leave", + style = MaterialTheme.typography.labelMedium, + ) + if (balance.pendingDays > 0) { + Text( + text = "%.1f pending".format(balance.pendingDays), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun RequestCard( + request: LeaveRequest, + type: LeaveType?, + onCancel: () -> Unit, +) { + val dateFormat = DateTimeFormatter.ofPattern("d MMM") + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + type?.let { + ColorDotChip( + text = it.name, + dotColor = parseHexColor(it.colorHex), + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "${request.startDate.format(dateFormat)} – " + + "${request.endDate.format(dateFormat)} · %.1f days".format(request.days), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = request.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + StatusChip(text = request.status.label(), tone = request.status.tone()) + } + if (request.syncStatus == SyncStatus.PENDING) { + Text( + text = "Waiting to sync…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (request.syncStatus == SyncStatus.FAILED) { + Text( + text = "Sync failed — the server rejected this request", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (request.status == LeaveStatus.PENDING && request.syncStatus == SyncStatus.SYNCED) { + TextButton(onClick = onCancel) { Text("Cancel request") } + } + } + } +} + +internal fun LeaveStatus.label(): String = when (this) { + LeaveStatus.DRAFT -> "Draft" + LeaveStatus.PENDING -> "Pending" + LeaveStatus.APPROVED -> "Approved" + LeaveStatus.REJECTED -> "Rejected" + LeaveStatus.CANCELLED -> "Cancelled" +} + +internal fun LeaveStatus.tone(): ChipTone = when (this) { + LeaveStatus.APPROVED -> ChipTone.POSITIVE + LeaveStatus.PENDING, LeaveStatus.DRAFT -> ChipTone.WARNING + LeaveStatus.REJECTED -> ChipTone.NEGATIVE + LeaveStatus.CANCELLED -> ChipTone.NEUTRAL +} + +internal fun parseHexColor(hex: String): Color = try { + Color(android.graphics.Color.parseColor(hex)) +} catch (_: IllegalArgumentException) { + Color(0xFF607D8B) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt new file mode 100644 index 0000000..6919442 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt @@ -0,0 +1,58 @@ +package app.worktrack.feature.leave.overview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.userMessage +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.domain.usecase.leave.CancelLeaveRequestUseCase +import app.worktrack.core.domain.usecase.leave.LeaveOverview +import app.worktrack.core.domain.usecase.leave.ObserveLeaveOverviewUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class LeaveOverviewUiState( + val overview: LeaveOverview = LeaveOverview(emptyList(), emptyList(), emptyList()), + val isApprover: Boolean = false, +) + +@HiltViewModel +class LeaveOverviewViewModel @Inject constructor( + observeOverview: ObserveLeaveOverviewUseCase, + observeSession: ObserveSessionUseCase, + private val cancelRequest: CancelLeaveRequestUseCase, +) : ViewModel() { + + private val _messages = Channel(Channel.BUFFERED) + val messages = _messages.receiveAsFlow() + + val uiState: StateFlow = combine( + observeOverview(), + observeSession(), + ) { overview, session -> + LeaveOverviewUiState( + overview = overview, + isApprover = session?.isApprover == true, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = LeaveOverviewUiState(), + ) + + fun onCancelRequest(requestId: String) { + viewModelScope.launch { + when (val result = cancelRequest(requestId)) { + is AppResult.Success -> _messages.send("Request cancelled") + is AppResult.Failure -> _messages.send(result.error.userMessage()) + } + } + } +} diff --git a/feature/payslips/build.gradle.kts b/feature/payslips/build.gradle.kts new file mode 100644 index 0000000..cd9396a --- /dev/null +++ b/feature/payslips/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.payslips" +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt new file mode 100644 index 0000000..6e76b53 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt @@ -0,0 +1,112 @@ +package app.worktrack.feature.payslips + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ReceiptLong +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.model.Payslip +import java.time.Month +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun PayslipsRoute( + onPayslipClick: (String) -> Unit, + viewModel: PayslipsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = viewModel::onPreviousYear) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous year") + } + Text( + text = state.year.toString(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + IconButton(onClick = viewModel::onNextYear, enabled = state.canGoForward) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next year") + } + } + + if (state.payslips.isEmpty()) { + EmptyState( + icon = Icons.Filled.ReceiptLong, + title = "No payslips for ${state.year}", + message = "Payslips appear here once payroll is finalized.", + ) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.payslips, key = { it.id }) { payslip -> + PayslipCard(payslip = payslip, onClick = { onPayslipClick(payslip.id) }) + } + } + } + } +} + +@Composable +private fun PayslipCard(payslip: Payslip, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clickable(onClick = onClick), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = "${ + Month.of(payslip.periodMonth).getDisplayName(TextStyle.FULL, Locale.getDefault()) + } ${payslip.periodYear}", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Worked %.1f days".format(payslip.workedDays) + + if (payslip.lopDays > 0) " · LOP %.1f".format(payslip.lopDays) else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "${payslip.currency} ${"%,.2f".format(payslip.net)}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt new file mode 100644 index 0000000..05aafed --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt @@ -0,0 +1,70 @@ +package app.worktrack.feature.payslips + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.domain.usecase.payslip.ObservePayslipsUseCase +import app.worktrack.core.model.Payslip +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class PayslipsUiState( + val year: Int, + val payslips: List = emptyList(), + val canGoForward: Boolean = false, +) + +@HiltViewModel +class PayslipsViewModel @Inject constructor( + observePayslips: ObservePayslipsUseCase, + private val payslipRepository: PayslipRepository, + private val timeProvider: TimeProvider, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val year: StateFlow = + savedStateHandle.getStateFlow(KEY_YEAR, timeProvider.today().year) + + val uiState: StateFlow = year + .flatMapLatest { selected -> observePayslips(selected) } + .combine(year) { slips, selected -> + PayslipsUiState( + year = selected, + payslips = slips, + canGoForward = selected < timeProvider.today().year, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PayslipsUiState(year = year.value), + ) + + init { + // Historic years are outside the delta-sync hot window; fetch on open. + viewModelScope.launch { payslipRepository.refresh(year.value) } + } + + fun onPreviousYear() = shiftYear(-1) + + fun onNextYear() = shiftYear(+1) + + private fun shiftYear(delta: Int) { + val target = year.value + delta + if (target > timeProvider.today().year) return + savedStateHandle[KEY_YEAR] = target + viewModelScope.launch { payslipRepository.refresh(target) } + } + + private companion object { + const val KEY_YEAR = "year" + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt new file mode 100644 index 0000000..2531ff4 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt @@ -0,0 +1,141 @@ +package app.worktrack.feature.payslips.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.model.PayComponentType +import app.worktrack.core.model.Payslip +import java.time.Month +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun PayslipDetailRoute( + onBack: () -> Unit, + viewModel: PayslipDetailViewModel = hiltViewModel(), +) { + val payslip by viewModel.payslip.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + WtTopBar( + title = payslip?.let { + "${Month.of(it.periodMonth).getDisplayName(TextStyle.SHORT, Locale.getDefault())} ${it.periodYear}" + } ?: "Payslip", + onBack = onBack, + ) + }, + ) { padding -> + when (val slip = payslip) { + null -> FullScreenLoading(Modifier.padding(padding)) + else -> PayslipDetail(payslip = slip, modifier = Modifier.padding(padding)) + } + } +} + +@Composable +private fun PayslipDetail(payslip: Payslip, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(Modifier.padding(16.dp)) { + Text("Net pay", style = MaterialTheme.typography.labelMedium) + Text( + text = "${payslip.currency} ${"%,.2f".format(payslip.net)}", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Gross ${"%,.2f".format(payslip.gross)} − " + + "Deductions ${"%,.2f".format(payslip.totalDeductions)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val earnings = payslip.lines.filter { it.type == PayComponentType.EARNING } + val deductions = payslip.lines.filter { it.type == PayComponentType.DEDUCTION } + + if (earnings.isNotEmpty()) { + SectionHeader("Earnings") + LinesCard(lines = earnings.map { it.componentName to it.amount }, currency = payslip.currency) + } + if (deductions.isNotEmpty()) { + SectionHeader("Deductions") + LinesCard(lines = deductions.map { it.componentName to it.amount }, currency = payslip.currency) + } + + SectionHeader("Attendance summary") + LinesCard( + lines = listOf( + "Worked days" to payslip.workedDays, + "Paid leave days" to payslip.paidLeaveDays, + "Loss of pay days" to payslip.lopDays, + ), + currency = null, + ) + } +} + +@Composable +private fun LinesCard(lines: List>, currency: String?) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + lines.forEachIndexed { index, (name, amount) -> + if (index > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Row { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = if (currency != null) { + "$currency ${"%,.2f".format(amount)}" + } else { + "%.1f".format(amount) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt new file mode 100644 index 0000000..cff0635 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt @@ -0,0 +1,30 @@ +package app.worktrack.feature.payslips.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.payslip.ObservePayslipDetailUseCase +import app.worktrack.core.model.Payslip +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PayslipDetailViewModel @Inject constructor( + observePayslipDetail: ObservePayslipDetailUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val payslipId: String = checkNotNull(savedStateHandle[ARG_PAYSLIP_ID]) { + "payslipId navigation argument is required" + } + + val payslip: StateFlow = observePayslipDetail(payslipId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + companion object { + const val ARG_PAYSLIP_ID = "payslipId" + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt new file mode 100644 index 0000000..0dea1bb --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt @@ -0,0 +1,38 @@ +package app.worktrack.feature.payslips.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import app.worktrack.feature.payslips.PayslipsRoute +import app.worktrack.feature.payslips.detail.PayslipDetailRoute +import app.worktrack.feature.payslips.detail.PayslipDetailViewModel + +const val PAYSLIPS_ROUTE = "payslips" +const val PAYSLIP_DETAIL_ROUTE = "payslips/{${PayslipDetailViewModel.ARG_PAYSLIP_ID}}" + +fun payslipDetailRoute(payslipId: String) = "payslips/$payslipId" + +fun NavGraphBuilder.payslipScreens(navController: NavController) { + composable(route = PAYSLIPS_ROUTE) { + PayslipsRoute( + onPayslipClick = { id -> navController.navigate(payslipDetailRoute(id)) }, + ) + } + + composable( + route = PAYSLIP_DETAIL_ROUTE, + arguments = listOf( + navArgument(PayslipDetailViewModel.ARG_PAYSLIP_ID) { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { + uriPattern = "worktrack://payslips/{${PayslipDetailViewModel.ARG_PAYSLIP_ID}}" + }, + ), + ) { + PayslipDetailRoute(onBack = { navController.popBackStack() }) + } +} diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 0000000..44b4060 --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.profile" +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt new file mode 100644 index 0000000..90e9148 --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt @@ -0,0 +1,183 @@ +package app.worktrack.feature.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.UserSession +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun ProfileRoute( + onPayslipsClick: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val session = state.session + if (session == null) { + FullScreenLoading() + return + } + ProfileScreen( + session = session, + syncState = state.syncState, + isSigningOut = state.isSigningOut, + onPayslipsClick = onPayslipsClick, + onSyncNow = viewModel::onSyncNow, + onSignOut = viewModel::onSignOut, + ) +} + +@Composable +internal fun ProfileScreen( + session: UserSession, + syncState: SyncState?, + isSigningOut: Boolean, + onPayslipsClick: () -> Unit, + onSyncNow: () -> Unit, + onSignOut: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Card( + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column(Modifier.padding(16.dp)) { + Text(session.displayName, style = MaterialTheme.typography.titleLarge) + Text( + text = session.email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = session.companyName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + session.roles.forEach { role -> + StatusChip( + text = role.name.replace('_', ' ').lowercase() + .replaceFirstChar { it.uppercase() }, + tone = ChipTone.NEUTRAL, + ) + } + } + } + } + + SectionHeader("Payroll") + WtSecondaryButton( + text = "My payslips", + onClick = onPayslipsClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + + SectionHeader("Sync") + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp)) { + SyncStatusRow(syncState) + Spacer(Modifier.height(12.dp)) + WtSecondaryButton(text = "Sync now", onClick = onSyncNow) + } + } + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = "Sign out", + onClick = onSignOut, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + loading = isSigningOut, + ) + } +} + +@Composable +private fun SyncStatusRow(syncState: SyncState?) { + if (syncState == null) { + Text("Sync status unavailable", style = MaterialTheme.typography.bodyMedium) + return + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + text = when { + syncState.isSyncing -> "Syncing…" + syncState.pendingOperations > 0 -> + "${syncState.pendingOperations} changes waiting to sync" + + else -> "Everything is up to date" + }, + style = MaterialTheme.typography.bodyMedium, + ) + syncState.lastSuccessAt?.let { + Text( + text = "Last synced " + DateTimeFormatter.ofPattern("d MMM HH:mm") + .format(it.atZone(ZoneId.systemDefault())), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (syncState.failedOperations > 0) { + Text( + text = "${syncState.failedOperations} changes were rejected by the server", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + StatusChip( + text = when { + syncState.isSyncing -> "SYNCING" + syncState.failedOperations > 0 -> "ATTENTION" + syncState.pendingOperations > 0 -> "PENDING" + else -> "OK" + }, + tone = when { + syncState.failedOperations > 0 -> ChipTone.NEGATIVE + syncState.pendingOperations > 0 || syncState.isSyncing -> ChipTone.WARNING + else -> ChipTone.POSITIVE + }, + ) + } +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt new file mode 100644 index 0000000..ec7f13b --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt @@ -0,0 +1,57 @@ +package app.worktrack.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.domain.usecase.auth.SignOutUseCase +import app.worktrack.core.domain.usecase.sync.ObserveSyncStateUseCase +import app.worktrack.core.domain.usecase.sync.TriggerSyncUseCase +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.UserSession +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class ProfileUiState( + val session: UserSession? = null, + val syncState: SyncState? = null, + val isSigningOut: Boolean = false, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + observeSession: ObserveSessionUseCase, + observeSyncState: ObserveSyncStateUseCase, + private val signOut: SignOutUseCase, + private val triggerSync: TriggerSyncUseCase, +) : ViewModel() { + + private val signingOut = kotlinx.coroutines.flow.MutableStateFlow(false) + + val uiState: StateFlow = combine( + observeSession(), + observeSyncState(), + signingOut, + ) { session, syncState, isSigningOut -> + ProfileUiState(session = session, syncState = syncState, isSigningOut = isSigningOut) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProfileUiState(), + ) + + fun onSyncNow() = triggerSync() + + fun onSignOut() { + if (signingOut.value) return + signingOut.value = true + viewModelScope.launch { + signOut.invoke() + // No state reset needed: clearing the session flips the root nav graph. + } + } +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt new file mode 100644 index 0000000..599d870 --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt @@ -0,0 +1,13 @@ +package app.worktrack.feature.profile.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.profile.ProfileRoute + +const val PROFILE_ROUTE = "profile" + +fun NavGraphBuilder.profileScreen(onPayslipsClick: () -> Unit) { + composable(route = PROFILE_ROUTE) { + ProfileRoute(onPayslipsClick = onPayslipsClick) + } +} From 954d7597b44c74a565c02b7c3485da7092a6b7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:49:04 +0000 Subject: [PATCH 005/139] feat(backend): REST API v1 on Cloud Functions with Firestore rules TypeScript/Express API (strict mode, typechecked): token verification with tenant custom claims, deny-by-default RBAC permission catalog, RFC 7807 problem+json errors, Idempotency-Key replay guard, and audit logging. Attendance punches are validated server-side (geofence with accuracy credit, kiosk HMAC TOTP tokens, speed-of-travel plausibility) and recorded append-only with AttendanceDay recomputation. Leave requests reserve balances transactionally with approval routing. Sync protocol: batched outbox push with per-op results and per-type delta-cursor pull. Firestore rules deny all direct client access; composite indexes cover every query. README documents setup, provisioning, and the repo layout. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- README.md | 96 +- backend/.firebaserc.example | 6 + backend/firebase.json | 17 + backend/firestore.indexes.json | 86 + backend/firestore.rules | 12 + backend/functions/package-lock.json | 2843 +++++++++++++++++ backend/functions/package.json | 29 + backend/functions/src/app.ts | 40 + backend/functions/src/config.ts | 8 + backend/functions/src/index.ts | 21 + backend/functions/src/lib/errors.ts | 92 + backend/functions/src/lib/firestore.ts | 79 + backend/functions/src/lib/ids.ts | 37 + backend/functions/src/middleware/auth.ts | 70 + .../functions/src/middleware/idempotency.ts | 30 + backend/functions/src/middleware/rbac.ts | 90 + backend/functions/src/middleware/validate.ts | 19 + backend/functions/src/routes/announcements.ts | 92 + backend/functions/src/routes/attendance.ts | 84 + backend/functions/src/routes/leave.ts | 134 + backend/functions/src/routes/me.ts | 43 + backend/functions/src/routes/payslips.ts | 34 + backend/functions/src/routes/sync.ts | 232 ++ backend/functions/src/services/attendance.ts | 194 ++ backend/functions/src/services/geo.ts | 62 + backend/functions/src/services/kiosk.ts | 58 + backend/functions/src/services/leave.ts | 297 ++ backend/functions/src/services/punch.ts | 162 + backend/functions/tsconfig.json | 18 + 29 files changed, 4983 insertions(+), 2 deletions(-) create mode 100644 backend/.firebaserc.example create mode 100644 backend/firebase.json create mode 100644 backend/firestore.indexes.json create mode 100644 backend/firestore.rules create mode 100644 backend/functions/package-lock.json create mode 100644 backend/functions/package.json create mode 100644 backend/functions/src/app.ts create mode 100644 backend/functions/src/config.ts create mode 100644 backend/functions/src/index.ts create mode 100644 backend/functions/src/lib/errors.ts create mode 100644 backend/functions/src/lib/firestore.ts create mode 100644 backend/functions/src/lib/ids.ts create mode 100644 backend/functions/src/middleware/auth.ts create mode 100644 backend/functions/src/middleware/idempotency.ts create mode 100644 backend/functions/src/middleware/rbac.ts create mode 100644 backend/functions/src/middleware/validate.ts create mode 100644 backend/functions/src/routes/announcements.ts create mode 100644 backend/functions/src/routes/attendance.ts create mode 100644 backend/functions/src/routes/leave.ts create mode 100644 backend/functions/src/routes/me.ts create mode 100644 backend/functions/src/routes/payslips.ts create mode 100644 backend/functions/src/routes/sync.ts create mode 100644 backend/functions/src/services/attendance.ts create mode 100644 backend/functions/src/services/geo.ts create mode 100644 backend/functions/src/services/kiosk.ts create mode 100644 backend/functions/src/services/leave.ts create mode 100644 backend/functions/src/services/punch.ts create mode 100644 backend/functions/tsconfig.json diff --git a/README.md b/README.md index 7816061..e5e03da 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,94 @@ -# WorkTrack -Smart Workforce & Attendance Management +# WorkTrack — Smart Workforce & Attendance Management + +WorkTrack is a multi-tenant Workforce Management Platform (HRMS): attendance with GPS +geofencing and kiosk QR check-in, shift scheduling, leave management with approval +chains, payroll, announcements, analytics, and enterprise-grade security — designed +for organizations from small teams to 100,000+ employees. + +## Repository layout + +| Path | Contents | +|---|---| +| `docs/` | Complete design documentation (start at `docs/00-master-spec.md`) | +| `app/`, `core/`, `feature/` | Android app — Kotlin, Jetpack Compose (M3), MVVM + Clean Architecture, Hilt, Room, WorkManager, offline-first sync | +| `build-logic/` | Gradle convention plugins shared by all modules | +| `backend/` | Firebase backend — REST API v1 on Cloud Functions (TypeScript/Express), Firestore rules and indexes | + +## Design documentation + +1. [Master specification (source of truth)](docs/00-master-spec.md) +2. [Product requirements](docs/01-product-requirements.md) +3. [System architecture](docs/02-system-architecture.md) +4. [Database design & ER diagrams](docs/03-database-design.md) +5. [REST API design](docs/04-api-design.md) +6. [Android architecture & navigation](docs/05-android-architecture.md) +7. [Web admin console design](docs/06-web-admin-design.md) +8. [Security architecture](docs/07-security-architecture.md) +9. [Offline-first sync strategy](docs/08-sync-strategy.md) +10. [Development roadmap](docs/09-roadmap.md) + +## Android app + +Module graph (details in `docs/05-android-architecture.md`): + +``` +app → feature:{auth,dashboard,attendance,leave,payslips,profile} + → core:{data,sync} → core:{database,network,datastore} → core:{domain,model,common} + → core:designsystem +``` + +Key properties: + +- **Offline-first**: Room is the local source of truth; mutations queue in an outbox + with ULID idempotency keys and sync via WorkManager (`core/sync`). Punches are + append-only; the server is authoritative for balances, attendance days, payroll. +- **Attendance**: GPS punch with client+server geofence validation, mock-location + rejection, kiosk TOTP QR scanning (CameraX + ML Kit), monthly history. +- **Leave**: balances, apply flow with half-days, approver inbox with approve/reject. +- **Security**: Firebase Auth ID tokens, tenant/RBAC custom claims, no tokens stored + outside the Firebase SDK, cloud backup disabled for tenant data. + +### Building + +Prerequisites: JDK 17+, Android SDK 35. The Gradle wrapper is pinned (8.9). + +```bash +./gradlew :app:assembleDebug +./gradlew test # JVM unit tests (domain/common) +``` + +Firebase setup (one-time): create a Firebase project, enable Email/Password +authentication, then place `google-services.json` in `app/` (the Google Services +plugin is applied automatically when the file exists). Debug builds point the API +at the local Functions emulator (`app/build.gradle.kts` → `API_BASE_URL`). + +## Backend + +```bash +cd backend/functions +npm install +npm run typecheck # strict TypeScript +npm run serve # Firebase emulators: functions + firestore + auth +``` + +- REST API v1 (Express on Cloud Functions v2): `me`, `attendance` (punch validation: + geofence, kiosk HMAC token, speed-of-travel plausibility), `leave` (transactional + balance reservation + approval chain), `payslips`, `announcements`, and the + sync protocol (`POST /sync/push`, `GET /sync/pull` with per-type delta cursors). +- Firestore rules deny all direct client access — every read/write goes through the + API (deny-by-default RBAC middleware, RFC 7807 errors, audit log on privileged ops). +- Kiosk QR secret: `firebase functions:secrets:set KIOSK_HMAC_SECRET`. + +## Provisioning a tenant (P0) + +1. Create `companies/{cid}` with `name`, `timezone`, `currency`. +2. Create `companies/{cid}/employees/{eid}` documents and geofences/shifts/leaveTypes. +3. Create the Firebase Auth user and set custom claims + `{ cid, eid, r: ["EMPLOYEE"], b: [branchIds] }` (Admin SDK). +4. Sign in from the app — session bootstraps via `GET /v1/me`, then full sync runs. + +## Roadmap + +P0 (this repository) is the foundation described above. P1–P4 add rosters UI, +regularization, face verification and kiosk mode, the payroll engine, the React +web admin, BigQuery analytics, and AI insights — see `docs/09-roadmap.md`. diff --git a/backend/.firebaserc.example b/backend/.firebaserc.example new file mode 100644 index 0000000..46f292b --- /dev/null +++ b/backend/.firebaserc.example @@ -0,0 +1,6 @@ +{ + "projects": { + "default": "worktrack-dev", + "prod": "worktrack-prod" + } +} diff --git a/backend/firebase.json b/backend/firebase.json new file mode 100644 index 0000000..6d10c19 --- /dev/null +++ b/backend/firebase.json @@ -0,0 +1,17 @@ +{ + "functions": { + "source": "functions", + "runtime": "nodejs20", + "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "auth": { "port": 9099 }, + "functions": { "port": 5001 }, + "firestore": { "port": 8080 }, + "ui": { "enabled": true } + } +} diff --git a/backend/firestore.indexes.json b/backend/firestore.indexes.json new file mode 100644 index 0000000..4a3fddf --- /dev/null +++ b/backend/firestore.indexes.json @@ -0,0 +1,86 @@ +{ + "indexes": [ + { + "collectionGroup": "punches", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "punches", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "punchedAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "attendanceDays", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "attendanceDays", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "date", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "leaveRequests", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "leaveRequests", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "currentApproverId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "leaveBalances", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "payslips", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "payslips", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "periodYear", "order": "ASCENDING" }, + { "fieldPath": "periodMonth", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "shiftAssignments", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "employeeId", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] + } + ], + "fieldOverrides": [] +} diff --git a/backend/firestore.rules b/backend/firestore.rules new file mode 100644 index 0000000..f8ac463 --- /dev/null +++ b/backend/firestore.rules @@ -0,0 +1,12 @@ +rules_version = '2'; + +// WorkTrack: ALL reads and writes go through the REST API (Admin SDK), which +// enforces tenant isolation and RBAC. Client SDKs have no direct Firestore +// access — these rules are the defense-in-depth backstop, not the auth layer. +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/backend/functions/package-lock.json b/backend/functions/package-lock.json new file mode 100644 index 0000000..940e9c8 --- /dev/null +++ b/backend/functions/package-lock.json @@ -0,0 +1,2843 @@ +{ + "name": "worktrack-functions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worktrack-functions", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "firebase-admin": "^12.1.0", + "firebase-functions": "^5.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "typescript": "^5.5.4" + }, + "engines": { + "node": "20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz", + "integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "1.0.8", + "@firebase/database-types": "1.0.5", + "@types/node": "^22.0.1", + "farmhash-modern": "^1.1.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.7.0", + "@google-cloud/storage": "^7.7.0" + } + }, + "node_modules/firebase-admin/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/firebase-admin/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/firebase-functions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-5.1.1.tgz", + "integrity": "sha512-KkyKZE98Leg/C73oRyuUYox04PQeeBThdygMfeX+7t1cmKWYKa/ZieYa89U8GHgED+0mF7m7wfNZOfbURYxIKg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.5", + "@types/express": "4.17.3", + "cors": "^2.8.5", + "express": "^4.17.1", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" + }, + "engines": { + "node": ">=14.10.0" + }, + "peerDependencies": { + "firebase-admin": "^11.10.0 || ^12.0.0" + } + }, + "node_modules/firebase-functions/node_modules/@types/express": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz", + "integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "optional": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/functions/package.json b/backend/functions/package.json new file mode 100644 index 0000000..9ec41e4 --- /dev/null +++ b/backend/functions/package.json @@ -0,0 +1,29 @@ +{ + "name": "worktrack-functions", + "version": "1.0.0", + "private": true, + "description": "WorkTrack REST API v1 on Cloud Functions", + "engines": { + "node": "20" + }, + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions,firestore,auth", + "deploy": "firebase deploy --only functions", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "firebase-admin": "^12.1.0", + "firebase-functions": "^5.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "typescript": "^5.5.4" + } +} diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts new file mode 100644 index 0000000..52ffbec --- /dev/null +++ b/backend/functions/src/app.ts @@ -0,0 +1,40 @@ +import cors from "cors"; +import express from "express"; +import { errorHandler } from "./lib/errors"; +import { requireAuth } from "./middleware/auth"; +import { meRouter } from "./routes/me"; +import { attendanceRouter } from "./routes/attendance"; +import { leaveRouter } from "./routes/leave"; +import { payslipsRouter } from "./routes/payslips"; +import { announcementsRouter } from "./routes/announcements"; +import { syncRouter } from "./routes/sync"; + +/** + * WorkTrack REST API v1. Middleware chain per request: + * cors -> json -> requireAuth (verify token + tenant claims) -> route + * (route-level RBAC) -> handler -> problem+json error handler. + */ +export function createApp(): express.Express { + const app = express(); + app.disable("x-powered-by"); + app.use(cors({ origin: true })); + app.use(express.json({ limit: "1mb" })); + + // Unauthenticated liveness probe for uptime monitoring. + app.get("/v1/health", (_req, res) => { + res.json({ data: { status: "ok" } }); + }); + + const v1 = express.Router(); + v1.use(requireAuth); + v1.use("/me", meRouter); + v1.use("/attendance", attendanceRouter); + v1.use("/leave", leaveRouter); + v1.use("/payslips", payslipsRouter); + v1.use("/announcements", announcementsRouter); + v1.use("/sync", syncRouter); + app.use("/v1", v1); + + app.use(errorHandler); + return app; +} diff --git a/backend/functions/src/config.ts b/backend/functions/src/config.ts new file mode 100644 index 0000000..807e23a --- /dev/null +++ b/backend/functions/src/config.ts @@ -0,0 +1,8 @@ +import { defineSecret } from "firebase-functions/params"; + +/** + * HMAC secret for kiosk TOTP QR tokens. Managed via Secret Manager: + * firebase functions:secrets:set KIOSK_HMAC_SECRET + * For the emulator, place a value in functions/.secret.local. + */ +export const kioskSecret = defineSecret("KIOSK_HMAC_SECRET"); diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts new file mode 100644 index 0000000..c58ef6b --- /dev/null +++ b/backend/functions/src/index.ts @@ -0,0 +1,21 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { createApp } from "./app"; +import { kioskSecret } from "./config"; + +/** + * The WorkTrack REST API v1, served as a single HTTPS function behind + * `https://api.worktrack.app` (Hosting rewrite or Cloud Load Balancer). + * Scaling, TLS, and DDoS absorption are delegated to Google Front End. + */ +export const api = onRequest( + { + region: "us-central1", + secrets: [kioskSecret], + minInstances: 0, + maxInstances: 100, + concurrency: 80, + memory: "512MiB", + timeoutSeconds: 60, + }, + createApp(), +); diff --git a/backend/functions/src/lib/errors.ts b/backend/functions/src/lib/errors.ts new file mode 100644 index 0000000..7c77805 --- /dev/null +++ b/backend/functions/src/lib/errors.ts @@ -0,0 +1,92 @@ +import type { NextFunction, Request, Response } from "express"; + +/** Canonical machine-readable error codes (mirrored by the Android client). */ +export const ErrorCodes = { + UNAUTHENTICATED: "UNAUTHENTICATED", + PERMISSION_DENIED: "PERMISSION_DENIED", + TENANT_MISMATCH: "TENANT_MISMATCH", + NOT_FOUND: "NOT_FOUND", + VALIDATION_FAILED: "VALIDATION_FAILED", + IDEMPOTENCY_REPLAY: "IDEMPOTENCY_REPLAY", + GEOFENCE_VIOLATION: "GEOFENCE_VIOLATION", + KIOSK_TOKEN_INVALID: "KIOSK_TOKEN_INVALID", + INSUFFICIENT_LEAVE_BALANCE: "INSUFFICIENT_LEAVE_BALANCE", + INVALID_STATE: "INVALID_STATE", + UNSUPPORTED_RESOURCE: "UNSUPPORTED_RESOURCE", + CONFLICT: "CONFLICT", + RATE_LIMITED: "RATE_LIMITED", + INTERNAL: "INTERNAL", +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** Application error carrying an HTTP status and a stable code. */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + readonly detail: string, + readonly fieldErrors: Record = {}, + ) { + super(detail); + } + + static unauthenticated(detail = "Missing or invalid credentials"): ApiError { + return new ApiError(401, ErrorCodes.UNAUTHENTICATED, detail); + } + + static permissionDenied(detail = "Not allowed"): ApiError { + return new ApiError(403, ErrorCodes.PERMISSION_DENIED, detail); + } + + static notFound(detail = "Resource not found"): ApiError { + return new ApiError(404, ErrorCodes.NOT_FOUND, detail); + } + + static validation(detail: string, fieldErrors: Record = {}): ApiError { + return new ApiError(422, ErrorCodes.VALIDATION_FAILED, detail, fieldErrors); + } + + static business(code: ErrorCode, detail: string): ApiError { + return new ApiError(422, code, detail); + } +} + +/** RFC 7807 problem+json responder. */ +export function sendProblem(res: Response, error: ApiError): void { + res + .status(error.status) + .type("application/problem+json") + .json({ + type: `https://api.worktrack.app/errors/${error.code}`, + title: error.code, + status: error.status, + code: error.code, + detail: error.detail, + ...(Object.keys(error.fieldErrors).length > 0 ? { fieldErrors: error.fieldErrors } : {}), + }); +} + +/** Terminal express error handler: everything unexpected becomes a 500 problem. */ +export function errorHandler( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +): void { + if (err instanceof ApiError) { + sendProblem(res, err); + return; + } + console.error("Unhandled API error", err); + sendProblem(res, new ApiError(500, ErrorCodes.INTERNAL, "Internal server error")); +} + +/** Wraps async handlers so rejections reach the error handler. */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): (req: Request, res: Response, next: NextFunction) => void { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +} diff --git a/backend/functions/src/lib/firestore.ts b/backend/functions/src/lib/firestore.ts new file mode 100644 index 0000000..6be9b6f --- /dev/null +++ b/backend/functions/src/lib/firestore.ts @@ -0,0 +1,79 @@ +import { getApps, initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import type { CollectionReference, Firestore } from "firebase-admin/firestore"; + +// Self-contained initialization keeps module import order irrelevant. +if (getApps().length === 0) { + initializeApp(); +} + +export const db: Firestore = getFirestore(); + +/** Sub-collections of companies/{cid}. Names match the client's ResourceTypes. */ +export type TenantCollection = + | "branches" + | "departments" + | "positions" + | "employees" + | "roleAssignments" + | "devices" + | "geofences" + | "shifts" + | "shiftAssignments" + | "punches" + | "attendanceDays" + | "regularizations" + | "leaveTypes" + | "leavePolicies" + | "leaveBalances" + | "leaveRequests" + | "holidayCalendars" + | "salaryComponents" + | "salaryStructures" + | "employeeSalaries" + | "payrollRuns" + | "payslips" + | "announcements" + | "documents" + | "auditLogs" + | "notifications" + | "idempotencyKeys"; + +export function tenant(cid: string, collection: TenantCollection): CollectionReference { + return db.collection("companies").doc(cid).collection(collection); +} + +/** ISO string for wire DTOs from a stored Firestore Timestamp. */ +export function toIso(value: Timestamp | undefined | null): string | null { + return value ? value.toDate().toISOString() : null; +} + +export function nowTimestamp(): Timestamp { + return Timestamp.now(); +} + +/** Appends an immutable audit log entry. Never awaited on the hot path fails soft. */ +export async function audit( + cid: string, + entry: { + actorId: string; + actorRole: string; + action: string; + resourceType: string; + resourceId: string; + before?: unknown; + after?: unknown; + }, +): Promise { + try { + await tenant(cid, "auditLogs").add({ + ...entry, + before: entry.before ?? null, + after: entry.after ?? null, + at: nowTimestamp(), + }); + } catch (e) { + // Audit failures must not fail the business operation, but they are loud. + console.error("AUDIT_WRITE_FAILED", { cid, action: entry.action, error: e }); + } +} diff --git a/backend/functions/src/lib/ids.ts b/backend/functions/src/lib/ids.ts new file mode 100644 index 0000000..88e7b4b --- /dev/null +++ b/backend/functions/src/lib/ids.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "crypto"; + +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** + * ULID generator (Crockford base32, 48-bit time + 80-bit randomness) matching + * the Android client's implementation. IDs sort by creation time. + */ +export function ulid(timestamp: number = Date.now()): string { + const chars: string[] = new Array(26); + + let ts = timestamp; + for (let i = 9; i >= 0; i--) { + chars[i] = ENCODING[ts % 32]; + ts = Math.floor(ts / 32); + } + + const rnd = randomBytes(10); + let buffer = 0; + let bitsInBuffer = 0; + let out = 10; + for (const byte of rnd) { + buffer = (buffer << 8) | byte; + bitsInBuffer += 8; + while (bitsInBuffer >= 5) { + bitsInBuffer -= 5; + chars[out++] = ENCODING[(buffer >>> bitsInBuffer) & 0x1f]; + } + // Keep the working buffer within 32-bit int range. + buffer &= (1 << bitsInBuffer) - 1; + } + return chars.join(""); +} + +export function isValidUlid(value: string): boolean { + return /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value.toUpperCase()); +} diff --git a/backend/functions/src/middleware/auth.ts b/backend/functions/src/middleware/auth.ts new file mode 100644 index 0000000..32419d2 --- /dev/null +++ b/backend/functions/src/middleware/auth.ts @@ -0,0 +1,70 @@ +import type { NextFunction, Request, Response } from "express"; +import { getAuth } from "firebase-admin/auth"; +import { ApiError } from "../lib/errors"; + +/** Tenant/identity context resolved from verified Firebase custom claims. */ +export interface AuthContext { + uid: string; + companyId: string; + employeeId: string; + roles: string[]; + branchIds: string[]; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + auth?: AuthContext; + } + } +} + +/** + * Verifies the bearer ID token and loads tenant context from custom claims + * ({ cid, eid, r, b }). Every /v1 route runs behind this — deny by default. + */ +export async function requireAuth( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const header = req.header("Authorization") ?? ""; + const match = header.match(/^Bearer (.+)$/); + if (!match) { + throw ApiError.unauthenticated(); + } + + const decoded = await getAuth().verifyIdToken(match[1]).catch(() => { + throw ApiError.unauthenticated("Token is invalid or expired"); + }); + + const cid = decoded.cid as string | undefined; + const eid = decoded.eid as string | undefined; + if (!cid || !eid) { + // An account without tenant claims is not provisioned as an employee. + throw ApiError.permissionDenied("Account is not provisioned for any company"); + } + + req.auth = { + uid: decoded.uid, + companyId: cid, + employeeId: eid, + roles: Array.isArray(decoded.r) ? (decoded.r as string[]) : [], + branchIds: Array.isArray(decoded.b) ? (decoded.b as string[]) : [], + }; + next(); + } catch (err) { + next(err); + } +} + +/** Non-null auth accessor for handlers running behind requireAuth. */ +export function authOf(req: Request): AuthContext { + const auth = req.auth; + if (!auth) { + throw ApiError.unauthenticated(); + } + return auth; +} diff --git a/backend/functions/src/middleware/idempotency.ts b/backend/functions/src/middleware/idempotency.ts new file mode 100644 index 0000000..89bbfc4 --- /dev/null +++ b/backend/functions/src/middleware/idempotency.ts @@ -0,0 +1,30 @@ +import { nowTimestamp, tenant } from "../lib/firestore"; + +/** + * At-most-once guard for non-idempotent POSTs. Returns the stored response for + * a replayed key, or null when the key is fresh (caller then executes and + * records). Keys are tenant-scoped and expire via TTL policy on `expiresAt`. + */ +export async function checkIdempotency( + cid: string, + key: string, +): Promise { + const doc = await tenant(cid, "idempotencyKeys").doc(key).get(); + return doc.exists ? (doc.data()?.response ?? null) : null; +} + +export async function recordIdempotency( + cid: string, + key: string, + response: unknown, +): Promise { + const now = nowTimestamp(); + await tenant(cid, "idempotencyKeys") + .doc(key) + .set({ + response, + createdAt: now, + // Firestore TTL field: configure the policy on `expiresAt` in the console/IaC. + expiresAt: new Date(now.toDate().getTime() + 24 * 60 * 60 * 1000), + }); +} diff --git a/backend/functions/src/middleware/rbac.ts b/backend/functions/src/middleware/rbac.ts new file mode 100644 index 0000000..267f0dc --- /dev/null +++ b/backend/functions/src/middleware/rbac.ts @@ -0,0 +1,90 @@ +import type { NextFunction, Request, Response } from "express"; +import { ApiError } from "../lib/errors"; +import { authOf } from "./auth"; + +/** + * Permission catalog: role -> granted "resource:action" permissions. + * "*" grants everything (company scope). Enforcement is deny-by-default. + */ +const ROLE_PERMISSIONS: Record> = { + SUPER_ADMIN: new Set(["*"]), + COMPANY_ADMIN: new Set(["*"]), + HR_ADMIN: new Set([ + "employees:read", + "employees:write", + "attendance:read", + "attendance:write", + "attendance:approve", + "leave:read", + "leave:write", + "leave:approve", + "payroll:read", + "announcements:read", + "announcements:write", + "audit:read", + ]), + PAYROLL_ADMIN: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "payroll:read", + "payroll:run", + "payroll:approve", + ]), + BRANCH_MANAGER: new Set([ + "employees:read", + "attendance:read", + "attendance:approve", + "leave:read", + "leave:approve", + "rosters:read", + "rosters:write", + "announcements:read", + ]), + TEAM_LEAD: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "leave:approve", + "announcements:read", + ]), + EMPLOYEE: new Set([ + "self:punch", + "self:attendance", + "self:leave", + "self:payslips", + "announcements:read", + ]), + AUDITOR: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "payroll:read", + "audit:read", + ]), + KIOSK: new Set(["kiosk:issue"]), +}; + +export function hasPermission(roles: string[], permission: string): boolean { + return roles.some((role) => { + const granted = ROLE_PERMISSIONS[role]; + return granted !== undefined && (granted.has("*") || granted.has(permission)); + }); +} + +/** Express guard: 403 unless one of the caller's roles grants [permission]. */ +export function requirePermission(permission: string) { + return (req: Request, _res: Response, next: NextFunction): void => { + const auth = authOf(req); + if (!hasPermission(auth.roles, permission)) { + next(ApiError.permissionDenied(`Requires ${permission}`)); + return; + } + next(); + }; +} + +/** True when the caller may approve leave (any approver-capable role). */ +export function isApprover(roles: string[]): boolean { + return hasPermission(roles, "leave:approve"); +} diff --git a/backend/functions/src/middleware/validate.ts b/backend/functions/src/middleware/validate.ts new file mode 100644 index 0000000..23ac023 --- /dev/null +++ b/backend/functions/src/middleware/validate.ts @@ -0,0 +1,19 @@ +import type { Request } from "express"; +import type { ZodTypeAny, z } from "zod"; +import { ApiError } from "../lib/errors"; + +/** Parses and validates a request body; zod issues become 422 field errors. */ +export function parseBody(req: Request, schema: S): z.output { + const result = schema.safeParse(req.body); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const path = issue.path.join(".") || "_root"; + if (!(path in fieldErrors)) { + fieldErrors[path] = issue.message; + } + } + throw ApiError.validation("Request body failed validation", fieldErrors); + } + return result.data; +} diff --git a/backend/functions/src/routes/announcements.ts b/backend/functions/src/routes/announcements.ts new file mode 100644 index 0000000..c1613c3 --- /dev/null +++ b/backend/functions/src/routes/announcements.ts @@ -0,0 +1,92 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { ulid } from "../lib/ids"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; + +export const announcementsRouter = Router(); + +announcementsRouter.get( + "/", + requirePermission("announcements:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "announcements") + .where("publishedAt", "<=", nowTimestamp()) + .orderBy("publishedAt", "desc") + .limit(100) + .get(); + + const now = Date.now(); + res.json({ + data: snapshot.docs + .map((doc): Record => ({ id: doc.id, ...doc.data() })) + .filter((a) => { + const expires = a.expiresAt as Timestamp | null | undefined; + return !expires || expires.toMillis() > now; + }) + .map((a) => ({ + ...a, + publishedAt: toIso(a.publishedAt as Timestamp), + expiresAt: toIso((a.expiresAt as Timestamp | null | undefined) ?? null), + updatedAt: toIso(a.updatedAt as Timestamp), + })), + }); + }), +); + +const announcementCreateSchema = z.object({ + title: z.string().min(1).max(200), + body: z.string().min(1).max(5000), + priority: z.enum(["NORMAL", "IMPORTANT", "URGENT"]).default("NORMAL"), + publishAt: z.string().datetime().nullish(), + expiresAt: z.string().datetime().nullish(), +}); + +announcementsRouter.post( + "/", + requirePermission("announcements:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, announcementCreateSchema); + + const id = ulid(); + const now = nowTimestamp(); + const doc = { + companyId: auth.companyId, + title: payload.title, + body: payload.body, + priority: payload.priority, + publishedAt: payload.publishAt + ? Timestamp.fromDate(new Date(payload.publishAt)) + : now, + expiresAt: payload.expiresAt ? Timestamp.fromDate(new Date(payload.expiresAt)) : null, + createdBy: auth.employeeId, + createdByName: null, + updatedAt: now, + }; + await tenant(auth.companyId, "announcements").doc(id).create(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "announcements.create", + resourceType: "announcements", + resourceId: id, + after: { title: payload.title, priority: payload.priority }, + }); + + res.status(201).json({ + data: { + id, + ...doc, + publishedAt: toIso(doc.publishedAt), + expiresAt: toIso(doc.expiresAt), + updatedAt: toIso(doc.updatedAt), + }, + }); + }), +); diff --git a/backend/functions/src/routes/attendance.ts b/backend/functions/src/routes/attendance.ts new file mode 100644 index 0000000..6ce5eaf --- /dev/null +++ b/backend/functions/src/routes/attendance.ts @@ -0,0 +1,84 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { asyncHandler, ApiError } from "../lib/errors"; +import { tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { hasPermission, requirePermission } from "../middleware/rbac"; +import { checkIdempotency, recordIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { applyPunch, punchCreateSchema } from "../services/punch"; +import { kioskSecret } from "../config"; + +export const attendanceRouter = Router(); + +/** Direct online punch (web/kiosk clients; Android normally uses /sync/push). */ +attendanceRouter.post( + "/punches", + requirePermission("self:punch"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, punchCreateSchema); + + const idempotencyKey = req.header("Idempotency-Key"); + if (idempotencyKey) { + const replay = await checkIdempotency(auth.companyId, idempotencyKey); + if (replay !== null) { + res.json({ data: replay }); + return; + } + } + + const dto = await applyPunch(auth.companyId, auth.employeeId, payload, kioskSecret.value()); + + if (idempotencyKey) { + await recordIdempotency(auth.companyId, idempotencyKey, dto); + } + res.status(201).json({ data: dto }); + }), +); + +/** Attendance day projections for a date window (self, or any employee with attendance:read). */ +attendanceRouter.get( + "/days", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const from = String(req.query.from ?? ""); + const to = String(req.query.to ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { + throw ApiError.validation("from/to must be ISO dates (YYYY-MM-DD)"); + } + + const requested = String(req.query.employeeId ?? auth.employeeId); + if (requested !== auth.employeeId && !hasPermission(auth.roles, "attendance:read")) { + throw ApiError.permissionDenied("Requires attendance:read for other employees"); + } + + const snapshot = await tenant(auth.companyId, "attendanceDays") + .where("employeeId", "==", requested) + .where("date", ">=", from) + .where("date", "<=", to) + .orderBy("date", "desc") + .limit(400) + .get(); + + res.json({ + data: snapshot.docs.map((doc) => { + const d = doc.data(); + return { + id: doc.id, + employeeId: d.employeeId, + date: d.date, + shiftId: d.shiftId ?? null, + firstInAt: toIso(d.firstInAt as Timestamp | null), + lastOutAt: toIso(d.lastOutAt as Timestamp | null), + workedMinutes: d.workedMinutes ?? 0, + lateMinutes: d.lateMinutes ?? 0, + earlyOutMinutes: d.earlyOutMinutes ?? 0, + overtimeMinutes: d.overtimeMinutes ?? 0, + status: d.status ?? "PENDING", + updatedAt: toIso(d.updatedAt as Timestamp | null), + }; + }), + }); + }), +); diff --git a/backend/functions/src/routes/leave.ts b/backend/functions/src/routes/leave.ts new file mode 100644 index 0000000..61658a2 --- /dev/null +++ b/backend/functions/src/routes/leave.ts @@ -0,0 +1,134 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { isApprover } from "../middleware/rbac"; +import { checkIdempotency, recordIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { + cancelLeaveRequest, + createLeaveRequest, + decideLeaveRequest, + leaveCreateSchema, + leaveDecisionSchema, + leaveRequestToDto, +} from "../services/leave"; + +export const leaveRouter = Router(); + +leaveRouter.get( + "/types", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "leaveTypes") + .where("active", "==", true) + .get(); + res.json({ + data: snapshot.docs.map((doc) => ({ + id: doc.id, + companyId: auth.companyId, + ...doc.data(), + updatedAt: toIso(doc.data().updatedAt as Timestamp | null), + })), + }); + }), +); + +leaveRouter.get( + "/balances", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "leaveBalances") + .where("employeeId", "==", auth.employeeId) + .get(); + res.json({ + data: snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + updatedAt: toIso(doc.data().updatedAt as Timestamp | null), + })), + }); + }), +); + +/** scope=mine (default) or scope=approvals (requests waiting on the caller). */ +leaveRouter.get( + "/requests", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const scope = String(req.query.scope ?? "mine"); + + const query = + scope === "approvals" + ? tenant(auth.companyId, "leaveRequests") + .where("currentApproverId", "==", auth.employeeId) + .limit(200) + : tenant(auth.companyId, "leaveRequests") + .where("employeeId", "==", auth.employeeId) + .limit(200); + + if (scope === "approvals" && !isApprover(auth.roles)) { + throw ApiError.permissionDenied("Requires leave:approve"); + } + + const snapshot = await query.get(); + res.json({ + data: snapshot.docs.map((doc) => + leaveRequestToDto(doc.id, doc.data() as Parameters[1]), + ), + }); + }), +); + +leaveRouter.post( + "/requests", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, leaveCreateSchema); + const dto = await createLeaveRequest(auth.companyId, auth.employeeId, payload); + res.status(201).json({ data: dto }); + }), +); + +leaveRouter.post( + "/requests/:id/decide", + asyncHandler(async (req, res) => { + const auth = authOf(req); + if (!isApprover(auth.roles)) { + throw ApiError.permissionDenied("Requires leave:approve"); + } + const payload = parseBody(req, leaveDecisionSchema); + + const idempotencyKey = req.header("Idempotency-Key"); + if (idempotencyKey) { + const replay = await checkIdempotency(auth.companyId, idempotencyKey); + if (replay !== null) { + res.json({ data: replay }); + return; + } + } + + const dto = await decideLeaveRequest( + auth.companyId, + req.params.id, + auth.employeeId, + auth.roles, + payload.decision, + payload.note ?? null, + ); + if (idempotencyKey) { + await recordIdempotency(auth.companyId, idempotencyKey, dto); + } + res.json({ data: dto }); + }), +); + +leaveRouter.post( + "/requests/:id/cancel", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const dto = await cancelLeaveRequest(auth.companyId, req.params.id, auth.employeeId); + res.json({ data: dto }); + }), +); diff --git a/backend/functions/src/routes/me.ts b/backend/functions/src/routes/me.ts new file mode 100644 index 0000000..b241b6a --- /dev/null +++ b/backend/functions/src/routes/me.ts @@ -0,0 +1,43 @@ +import { Router } from "express"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, db } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; + +export const meRouter = Router(); + +/** Resolves the caller's profile + tenant context for session bootstrap. */ +meRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + + const [companySnap, employeeSnap] = await Promise.all([ + db.collection("companies").doc(auth.companyId).get(), + tenant(auth.companyId, "employees").doc(auth.employeeId).get(), + ]); + if (!companySnap.exists || !employeeSnap.exists) { + throw ApiError.permissionDenied("Account is not provisioned for any company"); + } + const employee = employeeSnap.data() as { + firstName?: string; + lastName?: string; + email?: string; + avatarUrl?: string | null; + }; + + res.json({ + data: { + uid: auth.uid, + companyId: auth.companyId, + companyName: (companySnap.data()?.name as string | undefined) ?? "", + employeeId: auth.employeeId, + displayName: + [employee.firstName, employee.lastName].filter(Boolean).join(" ") || "Employee", + email: employee.email ?? "", + avatarUrl: employee.avatarUrl ?? null, + roles: auth.roles, + branchIds: auth.branchIds, + }, + }); + }), +); diff --git a/backend/functions/src/routes/payslips.ts b/backend/functions/src/routes/payslips.ts new file mode 100644 index 0000000..de7a5c2 --- /dev/null +++ b/backend/functions/src/routes/payslips.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; + +export const payslipsRouter = Router(); + +/** Self-service payslips for one year. Finalized/paid slips only. */ +payslipsRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const year = Number.parseInt(String(req.query.year ?? ""), 10); + if (Number.isNaN(year) || year < 2000 || year > 2100) { + throw ApiError.validation("year query parameter is required"); + } + + const snapshot = await tenant(auth.companyId, "payslips") + .where("employeeId", "==", auth.employeeId) + .where("periodYear", "==", year) + .get(); + + res.json({ + data: snapshot.docs + .map((doc) => ({ id: doc.id, ...doc.data() })) + .filter((slip) => (slip as { status?: string }).status !== "DRAFT") + .map((slip) => ({ + ...slip, + updatedAt: toIso((slip as { updatedAt?: Timestamp }).updatedAt ?? null), + })), + }); + }), +); diff --git a/backend/functions/src/routes/sync.ts b/backend/functions/src/routes/sync.ts new file mode 100644 index 0000000..51545b5 --- /dev/null +++ b/backend/functions/src/routes/sync.ts @@ -0,0 +1,232 @@ +import { Router } from "express"; +import { FieldPath, Timestamp } from "firebase-admin/firestore"; +import type { Query } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { asyncHandler } from "../lib/errors"; +import { tenant } from "../lib/firestore"; +import type { TenantCollection } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { parseBody } from "../middleware/validate"; +import { applyPunch, punchCreateSchema } from "../services/punch"; +import { createLeaveRequest, leaveCreateSchema } from "../services/leave"; +import { kioskSecret } from "../config"; + +export const syncRouter = Router(); + +// ---------------------------------------------------------------------- push + +const syncOpSchema = z.object({ + opId: z.string().min(1), + opType: z.enum(["CREATE", "UPDATE", "DELETE"]), + resourceType: z.string().min(1), + resourceId: z.string().min(1), + idempotencyKey: z.string().min(1), + payload: z.record(z.unknown()), +}); + +const syncPushSchema = z.object({ + ops: z.array(syncOpSchema).min(1).max(100), +}); + +/** + * Batched outbox drain. Each op is applied independently and idempotently + * (client-generated ULIDs make replays no-ops); business rejections come back + * as per-op REJECTED results, never batch failures. + */ +syncRouter.post( + "/push", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { ops } = parseBody(req, syncPushSchema); + + const results = []; + for (const op of ops) { + try { + if (op.opType !== "CREATE") { + throw ApiError.business( + ErrorCodes.UNSUPPORTED_RESOURCE, + `${op.opType} is not supported via sync for ${op.resourceType}`, + ); + } + const resource = await applyOp(auth.companyId, auth.employeeId, op.resourceType, op.payload); + results.push({ opId: op.opId, status: "APPLIED", resource }); + } catch (err) { + if (err instanceof ApiError) { + results.push({ + opId: op.opId, + status: "REJECTED", + errorCode: err.code, + message: err.detail, + }); + } else { + // Infrastructure failure: fail the batch so the client retries it whole. + throw err; + } + } + } + res.json({ data: { results } }); + }), +); + +async function applyOp( + cid: string, + employeeId: string, + resourceType: string, + payload: Record, +): Promise> { + switch (resourceType) { + case "punches": + return applyPunch(cid, employeeId, punchCreateSchema.parse(payload), kioskSecret.value()); + case "leaveRequests": + return createLeaveRequest(cid, employeeId, leaveCreateSchema.parse(payload)); + default: + throw ApiError.business( + ErrorCodes.UNSUPPORTED_RESOURCE, + `Resource type ${resourceType} cannot be pushed`, + ); + } +} + +// ---------------------------------------------------------------------- pull + +/** How each resource type is scoped for delta pull. */ +type PullScope = "company" | "employee" | "employeeOrApprover" | "self"; + +const PULL_REGISTRY: Record = { + branches: { collection: "branches", scope: "company" }, + geofences: { collection: "geofences", scope: "company" }, + employees: { collection: "employees", scope: "self" }, + shifts: { collection: "shifts", scope: "company" }, + shiftAssignments: { collection: "shiftAssignments", scope: "employee" }, + leaveTypes: { collection: "leaveTypes", scope: "company" }, + leaveBalances: { collection: "leaveBalances", scope: "employee" }, + leaveRequests: { collection: "leaveRequests", scope: "employeeOrApprover" }, + punches: { collection: "punches", scope: "employee" }, + attendanceDays: { collection: "attendanceDays", scope: "employee" }, + payslips: { collection: "payslips", scope: "employee" }, + announcements: { collection: "announcements", scope: "company" }, +}; + +/** + * Delta pull for one resource type. Cursor = `${updatedAtMillis}_${docId}`; + * pagination orders by (updatedAt, __name__) so equal timestamps never skip. + */ +syncRouter.get( + "/pull", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const type = String(req.query.type ?? ""); + const entry = PULL_REGISTRY[type]; + if (!entry) { + throw ApiError.validation(`Unknown resource type '${type}'`); + } + const limit = Math.min(Number.parseInt(String(req.query.limit ?? "500"), 10) || 500, 500); + const cursor = req.query.cursor ? String(req.query.cursor) : null; + + const queries = buildQueries(auth.companyId, auth.employeeId, entry); + + // Merge the (1-2) scoped queries client-side; page size stays bounded. + const docs: FirebaseFirestore.QueryDocumentSnapshot[] = []; + for (let query of queries) { + query = query.orderBy("updatedAt", "asc").orderBy(FieldPath.documentId(), "asc"); + if (cursor) { + const [millisRaw, docId] = splitCursor(cursor); + query = query.startAfter(Timestamp.fromMillis(millisRaw), docId); + } + const snap = await query.limit(limit).get(); + docs.push(...snap.docs); + } + + docs.sort((a, b) => { + const ta = (a.data().updatedAt as Timestamp).toMillis(); + const tb = (b.data().updatedAt as Timestamp).toMillis(); + return ta !== tb ? ta - tb : a.id.localeCompare(b.id); + }); + const page = dedupeById(docs).slice(0, limit); + + const last = page[page.length - 1]; + const nextCursor = last + ? `${(last.data().updatedAt as Timestamp).toMillis()}_${last.id}` + : cursor; + + res.json({ + data: { + resourceType: type, + items: page.map((doc) => serializeDoc(doc.id, doc.data())), + nextCursor, + hasMore: page.length === limit, + }, + }); + }), +); + +function buildQueries( + cid: string, + employeeId: string, + entry: { collection: TenantCollection; scope: PullScope }, +): Query[] { + const base = tenant(cid, entry.collection); + switch (entry.scope) { + case "company": + return [base]; + case "employee": + return [base.where("employeeId", "==", employeeId)]; + case "employeeOrApprover": + return [ + base.where("employeeId", "==", employeeId), + base.where("currentApproverId", "==", employeeId), + ]; + case "self": + return [base.where(FieldPath.documentId(), "==", employeeId)]; + } +} + +function splitCursor(cursor: string): [number, string] { + const separator = cursor.indexOf("_"); + const millis = Number.parseInt(cursor.slice(0, separator), 10); + if (separator < 0 || Number.isNaN(millis)) { + throw ApiError.validation("Malformed cursor"); + } + return [millis, cursor.slice(separator + 1)]; +} + +function dedupeById( + docs: FirebaseFirestore.QueryDocumentSnapshot[], +): FirebaseFirestore.QueryDocumentSnapshot[] { + const seen = new Set(); + return docs.filter((doc) => { + if (seen.has(doc.ref.path)) { + return false; + } + seen.add(doc.ref.path); + return true; + }); +} + +/** Doc -> wire DTO: id injected, Timestamps to ISO strings (deep). */ +function serializeDoc(id: string, data: Record): Record { + return { id, ...convertTimestamps(data) }; +} + +function convertTimestamps(value: Record): Record { + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (raw instanceof Timestamp) { + out[key] = raw.toDate().toISOString(); + } else if (Array.isArray(raw)) { + out[key] = raw.map((item) => + item !== null && typeof item === "object" && !(item instanceof Timestamp) + ? convertTimestamps(item as Record) + : item instanceof Timestamp + ? item.toDate().toISOString() + : item, + ); + } else if (raw !== null && typeof raw === "object") { + out[key] = convertTimestamps(raw as Record); + } else { + out[key] = raw; + } + } + return out; +} diff --git a/backend/functions/src/services/attendance.ts b/backend/functions/src/services/attendance.ts new file mode 100644 index 0000000..ed48e54 --- /dev/null +++ b/backend/functions/src/services/attendance.ts @@ -0,0 +1,194 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { nowTimestamp, tenant, toIso } from "../lib/firestore"; + +export interface PunchDoc { + companyId: string; + employeeId: string; + punchedAt: Timestamp; + type: "IN" | "OUT"; + method: string; + latitude: number | null; + longitude: number | null; + accuracyMeters: number | null; + geofenceId: string | null; + insideFence: boolean; + kioskId: string | null; + note: string | null; + serverValidated: boolean; + invalidReason: string | null; + updatedAt: Timestamp; +} + +export function punchToDto(id: string, doc: PunchDoc): Record { + return { + id, + companyId: doc.companyId, + employeeId: doc.employeeId, + punchedAt: toIso(doc.punchedAt), + type: doc.type, + method: doc.method, + latitude: doc.latitude, + longitude: doc.longitude, + accuracyMeters: doc.accuracyMeters, + geofenceId: doc.geofenceId, + insideFence: doc.insideFence, + note: doc.note, + serverValidated: doc.serverValidated, + invalidReason: doc.invalidReason, + updatedAt: toIso(doc.updatedAt), + }; +} + +/** + * Recomputes the AttendanceDay projection for one employee/date from the raw + * punch stream. Runs after each accepted punch; shift matching, late/overtime + * math against shift grace windows is applied when an assignment exists. + */ +export async function recomputeAttendanceDay( + cid: string, + employeeId: string, + dateIso: string, + timezone: string, +): Promise { + const dayStart = new Date(`${dateIso}T00:00:00Z`); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + const punchesSnap = await tenant(cid, "punches") + .where("employeeId", "==", employeeId) + .where("punchedAt", ">=", Timestamp.fromDate(dayStart)) + .where("punchedAt", "<", Timestamp.fromDate(dayEnd)) + .orderBy("punchedAt", "asc") + .get(); + + const punches = punchesSnap.docs + .map((d) => d.data() as PunchDoc) + .filter((p) => p.serverValidated); + + let workedMinutes = 0; + let firstInAt: Timestamp | null = null; + let lastOutAt: Timestamp | null = null; + let openIn: Timestamp | null = null; + for (const punch of punches) { + if (punch.type === "IN") { + if (!firstInAt) firstInAt = punch.punchedAt; + if (!openIn) openIn = punch.punchedAt; + } else if (openIn) { + workedMinutes += Math.floor( + (punch.punchedAt.toMillis() - openIn.toMillis()) / 60_000, + ); + lastOutAt = punch.punchedAt; + openIn = null; + } + } + + // Shift-aware late/early metrics when a roster assignment exists. + const assignmentSnap = await tenant(cid, "shiftAssignments") + .where("employeeId", "==", employeeId) + .where("date", "==", dateIso) + .limit(1) + .get(); + + let shiftId: string | null = null; + let lateMinutes = 0; + let earlyOutMinutes = 0; + let overtimeMinutes = 0; + + if (!assignmentSnap.empty && firstInAt) { + const assignment = assignmentSnap.docs[0].data() as { shiftId: string }; + shiftId = assignment.shiftId; + const shiftDoc = await tenant(cid, "shifts").doc(shiftId).get(); + if (shiftDoc.exists) { + const shift = shiftDoc.data() as { + startTime: string; // HH:mm in branch-local time + endTime: string; + graceInMinutes: number; + graceOutMinutes: number; + breakMinutes: number; + }; + const shiftStart = localTimeToUtc(dateIso, shift.startTime, timezone); + const shiftEnd = localTimeToUtc(dateIso, shift.endTime, timezone); + + const lateBy = Math.floor((firstInAt.toMillis() - shiftStart.getTime()) / 60_000); + lateMinutes = Math.max(0, lateBy - shift.graceInMinutes); + + if (lastOutAt) { + const earlyBy = Math.floor((shiftEnd.getTime() - lastOutAt.toMillis()) / 60_000); + earlyOutMinutes = Math.max(0, earlyBy - shift.graceOutMinutes); + + const scheduled = + Math.floor((shiftEnd.getTime() - shiftStart.getTime()) / 60_000) - shift.breakMinutes; + overtimeMinutes = Math.max(0, workedMinutes - scheduled); + } + } + } + + const status = firstInAt ? (workedMinutes >= 240 ? "PRESENT" : "HALF_DAY") : "PENDING"; + + const dayId = `${employeeId}_${dateIso}`; + await tenant(cid, "attendanceDays") + .doc(dayId) + .set({ + employeeId, + date: dateIso, + shiftId, + firstInAt, + lastOutAt, + workedMinutes, + lateMinutes, + earlyOutMinutes, + overtimeMinutes, + status, + computedAt: nowTimestamp(), + updatedAt: nowTimestamp(), + }); +} + +/** + * Converts a local wall-clock HH:mm on a date to a UTC Date using the IANA + * timezone, correct across DST via Intl (no external tz library needed). + */ +function localTimeToUtc(dateIso: string, hhmm: string, timezone: string): Date { + const [hours, minutes] = hhmm.split(":").map((v) => Number.parseInt(v, 10)); + const naive = new Date(`${dateIso}T${hhmm.padStart(5, "0")}:00Z`); + // Offset of the target zone at that moment, in minutes. + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + const parts = Object.fromEntries( + formatter.formatToParts(naive).map((p) => [p.type, p.value]), + ); + const zoned = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour === "24" ? "0" : parts.hour), + Number(parts.minute), + ); + const offsetMillis = zoned - naive.getTime(); + return new Date( + Date.UTC( + Number(dateIso.slice(0, 4)), + Number(dateIso.slice(5, 7)) - 1, + Number(dateIso.slice(8, 10)), + hours, + minutes, + ) - offsetMillis, + ); +} + +/** Local calendar date (YYYY-MM-DD) of an instant in the given timezone. */ +export function localDateOf(at: Date, timezone: string): string { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(at); // en-CA yields YYYY-MM-DD +} diff --git a/backend/functions/src/services/geo.ts b/backend/functions/src/services/geo.ts new file mode 100644 index 0000000..ec6a7e4 --- /dev/null +++ b/backend/functions/src/services/geo.ts @@ -0,0 +1,62 @@ +import { tenant } from "../lib/firestore"; + +const EARTH_RADIUS_METERS = 6_371_000; + +export function haversineMeters( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const toRad = (deg: number): number => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export interface GeofenceCheck { + fencesConfigured: boolean; + insideFence: boolean; + geofenceId: string | null; + distanceMeters: number | null; +} + +/** + * Server-authoritative geofence validation; never trusts the client's + * insideFence flag. GPS accuracy is credited toward the fence, mirroring + * the client heuristic so both sides agree. + */ +export async function checkGeofence( + cid: string, + latitude: number, + longitude: number, + accuracyMeters: number, +): Promise { + const snapshot = await tenant(cid, "geofences").where("active", "==", true).get(); + if (snapshot.empty) { + return { fencesConfigured: false, insideFence: false, geofenceId: null, distanceMeters: null }; + } + + let nearestId: string | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearestRadius = 0; + for (const doc of snapshot.docs) { + const fence = doc.data() as { latitude: number; longitude: number; radiusMeters: number }; + const distance = haversineMeters(latitude, longitude, fence.latitude, fence.longitude); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestId = doc.id; + nearestRadius = fence.radiusMeters; + } + } + + return { + fencesConfigured: true, + insideFence: nearestDistance - accuracyMeters <= nearestRadius, + geofenceId: nearestId, + distanceMeters: Math.round(nearestDistance), + }; +} diff --git a/backend/functions/src/services/kiosk.ts b/backend/functions/src/services/kiosk.ts new file mode 100644 index 0000000..242988a --- /dev/null +++ b/backend/functions/src/services/kiosk.ts @@ -0,0 +1,58 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +/** + * Kiosk QR tokens: `kioskId.slot.signature` where slot = floor(unixSeconds/30) + * and signature = HMAC-SHA256(secret, `${kioskId}.${slot}`) hex. The kiosk app + * regenerates the QR every 30 seconds; scanning a stale or forged code fails. + */ +export interface KioskToken { + kioskId: string; + slot: number; +} + +export function signKioskToken( + secret: string, + kioskId: string, + slot: number = currentSlot(), +): string { + return `${kioskId}.${slot}.${signature(secret, kioskId, slot)}`; +} + +/** + * Verifies the token against the current slot ± 1 (90-second acceptance + * window covers clock skew and scan latency). Returns null when invalid. + */ +export function verifyKioskToken(secret: string, token: string): KioskToken | null { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + const [kioskId, slotRaw, provided] = parts; + const slot = Number.parseInt(slotRaw, 10); + if (!kioskId || Number.isNaN(slot)) { + return null; + } + + const now = currentSlot(); + for (const candidate of [now, now - 1, now + 1]) { + if (candidate !== slot) { + continue; + } + const expected = signature(secret, kioskId, candidate); + if ( + provided.length === expected.length && + timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8")) + ) { + return { kioskId, slot: candidate }; + } + } + return null; +} + +function currentSlot(): number { + return Math.floor(Date.now() / 1000 / 30); +} + +function signature(secret: string, kioskId: string, slot: number): string { + return createHmac("sha256", secret).update(`${kioskId}.${slot}`).digest("hex"); +} diff --git a/backend/functions/src/services/leave.ts b/backend/functions/src/services/leave.ts new file mode 100644 index 0000000..1165e2d --- /dev/null +++ b/backend/functions/src/services/leave.ts @@ -0,0 +1,297 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { isValidUlid } from "../lib/ids"; +import { audit, db, nowTimestamp, tenant, toIso } from "../lib/firestore"; + +export const leaveCreateSchema = z.object({ + id: z.string().length(26), + leaveTypeId: z.string().min(1), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + startHalfDay: z.boolean().optional().default(false), + endHalfDay: z.boolean().optional().default(false), + reason: z.string().min(1).max(1000), +}); + +export type LeaveCreate = z.infer; + +export const leaveDecisionSchema = z.object({ + decision: z.enum(["APPROVE", "REJECT"]), + note: z.string().max(1000).nullish(), +}); + +interface LeaveRequestDoc { + companyId: string; + employeeId: string; + employeeName: string | null; + leaveTypeId: string; + startDate: string; + endDate: string; + startHalfDay: boolean; + endHalfDay: boolean; + days: number; + reason: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "CANCELLED"; + currentApproverId: string | null; + decidedAt: Timestamp | null; + decidedBy: string | null; + decisionNote: string | null; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +export function leaveRequestToDto(id: string, doc: LeaveRequestDoc): Record { + return { + id, + companyId: doc.companyId, + employeeId: doc.employeeId, + employeeName: doc.employeeName, + leaveTypeId: doc.leaveTypeId, + startDate: doc.startDate, + endDate: doc.endDate, + startHalfDay: doc.startHalfDay, + endHalfDay: doc.endHalfDay, + days: doc.days, + reason: doc.reason, + status: doc.status, + currentApproverId: doc.currentApproverId, + decidedAt: toIso(doc.decidedAt), + decisionNote: doc.decisionNote, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +/** Calendar-day count with half-day trims; must match the client's estimate. */ +export function calculateDays(payload: LeaveCreate): number { + const start = new Date(`${payload.startDate}T00:00:00Z`); + const end = new Date(`${payload.endDate}T00:00:00Z`); + const span = Math.floor((end.getTime() - start.getTime()) / 86_400_000) + 1; + if (span <= 0) { + return 0; + } + if (span === 1) { + return payload.startHalfDay || payload.endHalfDay ? 0.5 : 1; + } + let days = span; + if (payload.startHalfDay) days -= 0.5; + if (payload.endHalfDay) days -= 0.5; + return days; +} + +/** + * Creates a leave request transactionally: validates the authoritative balance, + * reserves pendingDays, and routes to the employee's manager for approval. + * Idempotent on the client-generated ULID. + */ +export async function createLeaveRequest( + cid: string, + employeeId: string, + payload: LeaveCreate, +): Promise> { + if (!isValidUlid(payload.id)) { + throw ApiError.validation("Request id must be a ULID", { id: "Invalid ULID" }); + } + const days = calculateDays(payload); + if (days <= 0) { + throw ApiError.validation("End date must be on or after start date", { + endDate: "Invalid range", + }); + } + + const requestRef = tenant(cid, "leaveRequests").doc(payload.id); + const periodYear = Number(payload.startDate.slice(0, 4)); + + return db.runTransaction(async (tx) => { + const existing = await tx.get(requestRef); + if (existing.exists) { + return leaveRequestToDto(payload.id, existing.data() as LeaveRequestDoc); + } + + const employeeSnap = await tx.get(tenant(cid, "employees").doc(employeeId)); + if (!employeeSnap.exists) { + throw ApiError.notFound("Employee record not found"); + } + const employee = employeeSnap.data() as { + firstName?: string; + lastName?: string; + managerId?: string | null; + }; + + const balanceQuery = tenant(cid, "leaveBalances") + .where("employeeId", "==", employeeId) + .where("leaveTypeId", "==", payload.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1); + const balanceSnap = await tx.get(balanceQuery); + + if (!balanceSnap.empty) { + const balance = balanceSnap.docs[0].data() as { + entitledDays: number; + accruedDays: number; + usedDays: number; + carriedOverDays: number; + pendingDays: number; + }; + const available = + balance.entitledDays + + balance.accruedDays + + balance.carriedOverDays - + balance.usedDays - + balance.pendingDays; + if (days > available) { + throw ApiError.business( + ErrorCodes.INSUFFICIENT_LEAVE_BALANCE, + `Requested ${days} days but only ${available.toFixed(1)} available`, + ); + } + tx.update(balanceSnap.docs[0].ref, { + pendingDays: balance.pendingDays + days, + updatedAt: nowTimestamp(), + }); + } + + const now = nowTimestamp(); + const doc: LeaveRequestDoc = { + companyId: cid, + employeeId, + employeeName: + [employee.firstName, employee.lastName].filter(Boolean).join(" ") || null, + leaveTypeId: payload.leaveTypeId, + startDate: payload.startDate, + endDate: payload.endDate, + startHalfDay: payload.startHalfDay, + endHalfDay: payload.endHalfDay, + days, + reason: payload.reason, + status: "PENDING", + currentApproverId: employee.managerId ?? null, + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }; + tx.create(requestRef, doc); + return leaveRequestToDto(payload.id, doc); + }); +} + +/** Approves or rejects a PENDING request; moves the pendingDays reservation. */ +export async function decideLeaveRequest( + cid: string, + requestId: string, + decidedBy: string, + deciderRoles: string[], + decision: "APPROVE" | "REJECT", + note: string | null, +): Promise> { + const requestRef = tenant(cid, "leaveRequests").doc(requestId); + + const dto = await db.runTransaction(async (tx) => { + const snap = await tx.get(requestRef); + if (!snap.exists) { + throw ApiError.notFound("Leave request not found"); + } + const request = snap.data() as LeaveRequestDoc; + if (request.status !== "PENDING") { + throw ApiError.business(ErrorCodes.INVALID_STATE, `Request is already ${request.status}`); + } + + const isAssignedApprover = request.currentApproverId === decidedBy; + const isAdmin = deciderRoles.includes("HR_ADMIN") || deciderRoles.includes("COMPANY_ADMIN"); + if (!isAssignedApprover && !isAdmin) { + throw ApiError.permissionDenied("You are not the approver for this request"); + } + if (request.employeeId === decidedBy) { + throw ApiError.permissionDenied("You cannot decide your own leave request"); + } + + const periodYear = Number(request.startDate.slice(0, 4)); + const balanceQuery = tenant(cid, "leaveBalances") + .where("employeeId", "==", request.employeeId) + .where("leaveTypeId", "==", request.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1); + const balanceSnap = await tx.get(balanceQuery); + + if (!balanceSnap.empty) { + const balance = balanceSnap.docs[0].data() as { pendingDays: number; usedDays: number }; + const releasedPending = Math.max(0, balance.pendingDays - request.days); + tx.update(balanceSnap.docs[0].ref, { + pendingDays: releasedPending, + usedDays: decision === "APPROVE" ? balance.usedDays + request.days : balance.usedDays, + updatedAt: nowTimestamp(), + }); + } + + const updated: Partial = { + status: decision === "APPROVE" ? "APPROVED" : "REJECTED", + decidedAt: nowTimestamp(), + decidedBy, + decisionNote: note, + currentApproverId: null, + updatedAt: nowTimestamp(), + }; + tx.update(requestRef, updated); + return leaveRequestToDto(requestId, { ...request, ...updated } as LeaveRequestDoc); + }); + + await audit(cid, { + actorId: decidedBy, + actorRole: deciderRoles.join(","), + action: `leave.${decision.toLowerCase()}`, + resourceType: "leaveRequests", + resourceId: requestId, + after: { decision, note }, + }); + return dto; +} + +/** Owner-initiated cancellation of a PENDING request; releases the reservation. */ +export async function cancelLeaveRequest( + cid: string, + requestId: string, + employeeId: string, +): Promise> { + const requestRef = tenant(cid, "leaveRequests").doc(requestId); + + return db.runTransaction(async (tx) => { + const snap = await tx.get(requestRef); + if (!snap.exists) { + throw ApiError.notFound("Leave request not found"); + } + const request = snap.data() as LeaveRequestDoc; + if (request.employeeId !== employeeId) { + throw ApiError.permissionDenied("Only the requester can cancel"); + } + if (request.status !== "PENDING") { + throw ApiError.business(ErrorCodes.INVALID_STATE, `Request is already ${request.status}`); + } + + const periodYear = Number(request.startDate.slice(0, 4)); + const balanceSnap = await tx.get( + tenant(cid, "leaveBalances") + .where("employeeId", "==", request.employeeId) + .where("leaveTypeId", "==", request.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1), + ); + if (!balanceSnap.empty) { + const balance = balanceSnap.docs[0].data() as { pendingDays: number }; + tx.update(balanceSnap.docs[0].ref, { + pendingDays: Math.max(0, balance.pendingDays - request.days), + updatedAt: nowTimestamp(), + }); + } + + const updated: Partial = { + status: "CANCELLED", + currentApproverId: null, + updatedAt: nowTimestamp(), + }; + tx.update(requestRef, updated); + return leaveRequestToDto(requestId, { ...request, ...updated } as LeaveRequestDoc); + }); +} diff --git a/backend/functions/src/services/punch.ts b/backend/functions/src/services/punch.ts new file mode 100644 index 0000000..a1354ed --- /dev/null +++ b/backend/functions/src/services/punch.ts @@ -0,0 +1,162 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { isValidUlid } from "../lib/ids"; +import { audit, nowTimestamp, tenant } from "../lib/firestore"; +import { haversineMeters, checkGeofence } from "./geo"; +import { verifyKioskToken } from "./kiosk"; +import { localDateOf, punchToDto, recomputeAttendanceDay, type PunchDoc } from "./attendance"; + +export const punchCreateSchema = z.object({ + id: z.string().length(26), + punchedAt: z.string().datetime(), + type: z.enum(["IN", "OUT"]), + method: z.enum(["GPS", "QR", "FACE", "MANUAL", "KIOSK"]), + latitude: z.number().min(-90).max(90).nullish(), + longitude: z.number().min(-180).max(180).nullish(), + accuracyMeters: z.number().min(0).nullish(), + geofenceId: z.string().nullish(), + insideFence: z.boolean().optional().default(false), + kioskToken: z.string().nullish(), + note: z.string().max(500).nullish(), +}); + +export type PunchCreate = z.infer; + +const MAX_FUTURE_SKEW_MS = 10 * 60 * 1000; +const MAX_BACKDATE_MS = 7 * 24 * 60 * 60 * 1000; // multi-day offline window +const IMPLAUSIBLE_SPEED_KMH = 250; + +/** + * Applies one punch: append-only, idempotent on the client-generated ULID. + * The punch is always recorded as evidence; failed validations mark it + * serverValidated=false with a reason instead of dropping the event. + */ +export async function applyPunch( + cid: string, + employeeId: string, + payload: PunchCreate, + kioskSecret: string, +): Promise> { + if (!isValidUlid(payload.id)) { + throw ApiError.validation("Punch id must be a ULID", { id: "Invalid ULID" }); + } + + const ref = tenant(cid, "punches").doc(payload.id); + const existing = await ref.get(); + if (existing.exists) { + // Idempotent replay: the first write wins, return the stored state. + return punchToDto(payload.id, existing.data() as PunchDoc); + } + + const punchedAt = new Date(payload.punchedAt); + const now = Date.now(); + + let serverValidated = true; + let invalidReason: string | null = null; + let geofenceId: string | null = payload.geofenceId ?? null; + let insideFence = false; + let kioskId: string | null = null; + + if (punchedAt.getTime() > now + MAX_FUTURE_SKEW_MS) { + serverValidated = false; + invalidReason = "TIME_SKEW"; + } else if (punchedAt.getTime() < now - MAX_BACKDATE_MS) { + serverValidated = false; + invalidReason = "TOO_OLD"; + } + + if (serverValidated && payload.method === "GPS") { + if (payload.latitude == null || payload.longitude == null) { + throw ApiError.validation("GPS punches require coordinates", { + latitude: "Required for GPS method", + }); + } + const check = await checkGeofence( + cid, + payload.latitude, + payload.longitude, + payload.accuracyMeters ?? 0, + ); + geofenceId = check.geofenceId; + insideFence = check.insideFence; + if (check.fencesConfigured && !check.insideFence) { + serverValidated = false; + invalidReason = ErrorCodes.GEOFENCE_VIOLATION; + } + } + + if (serverValidated && payload.method === "QR") { + const token = payload.kioskToken ? verifyKioskToken(kioskSecret, payload.kioskToken) : null; + if (!token) { + serverValidated = false; + invalidReason = ErrorCodes.KIOSK_TOKEN_INVALID; + } else { + kioskId = token.kioskId; + insideFence = true; // physically at the kiosk + } + } + + // Speed-of-travel plausibility vs the most recent located, validated punch. + if (serverValidated && payload.latitude != null && payload.longitude != null) { + const prevSnap = await tenant(cid, "punches") + .where("employeeId", "==", employeeId) + .orderBy("punchedAt", "desc") + .limit(1) + .get(); + if (!prevSnap.empty) { + const prev = prevSnap.docs[0].data() as PunchDoc; + if (prev.serverValidated && prev.latitude != null && prev.longitude != null) { + const meters = haversineMeters( + prev.latitude, + prev.longitude, + payload.latitude, + payload.longitude, + ); + const hours = Math.max( + (punchedAt.getTime() - prev.punchedAt.toMillis()) / 3_600_000, + 1 / 3600, // floor at one second to avoid divide-by-zero + ); + if (meters / 1000 / hours > IMPLAUSIBLE_SPEED_KMH) { + serverValidated = false; + invalidReason = "IMPLAUSIBLE_TRAVEL"; + } + } + } + } + + const doc: PunchDoc = { + companyId: cid, + employeeId, + punchedAt: Timestamp.fromDate(punchedAt), + type: payload.type, + method: payload.method, + latitude: payload.latitude ?? null, + longitude: payload.longitude ?? null, + accuracyMeters: payload.accuracyMeters ?? null, + geofenceId, + insideFence, + kioskId, + note: payload.note ?? null, + serverValidated, + invalidReason, + updatedAt: nowTimestamp(), + }; + // create() (not set) preserves append-only semantics under write races. + await ref.create(doc); + + const companyDoc = await tenant(cid, "punches").parent!.get(); + const timezone = (companyDoc.data()?.timezone as string | undefined) ?? "UTC"; + await recomputeAttendanceDay(cid, employeeId, localDateOf(punchedAt, timezone), timezone); + + await audit(cid, { + actorId: employeeId, + actorRole: "EMPLOYEE", + action: "attendance.punch", + resourceType: "punches", + resourceId: payload.id, + after: { type: payload.type, method: payload.method, serverValidated, invalidReason }, + }); + + return punchToDto(payload.id, doc); +} diff --git a/backend/functions/tsconfig.json b/backend/functions/tsconfig.json new file mode 100644 index 0000000..ccf487c --- /dev/null +++ b/backend/functions/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "lib", + "rootDir": "src", + "strict": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} From 3e030a4146e25396ad5da317c593adda0ecad327 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 21:29:38 +0000 Subject: [PATCH 006/139] =?UTF-8?q?feat(l10n):=20Afghanistan-first=20local?= =?UTF-8?q?ization=20=E2=80=94=20Dari=20default,=20Pashto,=20Solar=20Hijri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkTrack is now built for Afghanistan in Dari and Pashto: - Dari (fa-AF) is the DEFAULT locale: base resources are Dari, so any unmatched device language falls back to Dari; full Pashto (values-ps) and English (values-en) translations across every module with module-prefixed resource names - Every hardcoded UI string extracted to resources; domain/server errors now travel as typed AppError and are localized at render time by stable business code (geofence, leave balance, kiosk token, ...) - Solar Hijri calendar as the business calendar: tested Gregorian <-> Shamsi converter in core:common, Afghan month names (حمل...حوت / وری...کب), attendance history paged by Shamsi month, payroll periods interpreted as Shamsi months, dates/times rendered with Eastern Arabic digits - In-app language picker (دری / پښتو / English) via AppCompatDelegate per-app locales + android:localeConfig for Android 13+, RTL-first UI - ViewModels refactored from string messages to typed effects so no English text leaks from domain to UI; field errors map by key - docs/10-localization-afghanistan.md: language policy, calendar strategy, Friday weekend, AFN/Asia-Kabul defaults, known gaps; master spec and README updated (README now opens in Dari) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- README.md | 17 ++- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 1 + .../main/kotlin/app/worktrack/MainActivity.kt | 6 +- .../kotlin/app/worktrack/ui/MainScaffold.kt | 17 ++- app/src/main/res/values-en/strings.xml | 8 ++ app/src/main/res/values-ps/strings.xml | 8 ++ app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/themes.xml | 5 +- app/src/main/res/xml/locales_config.xml | 8 ++ .../core/common/result/AppErrorMessages.kt | 16 --- .../worktrack/core/common/time/SolarHijri.kt | 128 ++++++++++++++++++ .../core/common/time/SolarHijriTest.kt | 67 +++++++++ core/designsystem/build.gradle.kts | 1 + .../designsystem/component/Scaffolding.kt | 4 +- .../core/designsystem/component/States.kt | 4 +- .../core/designsystem/l10n/AfghanFormat.kt | 113 ++++++++++++++++ .../core/designsystem/l10n/ErrorMessages.kt | 36 +++++ .../src/main/res/values-en/strings.xml | 49 +++++++ .../src/main/res/values-ps/strings.xml | 48 +++++++ .../src/main/res/values/strings.xml | 52 +++++++ docs/00-master-spec.md | 7 +- docs/10-localization-afghanistan.md | 97 +++++++++++++ .../history/AttendanceHistoryScreen.kt | 78 +++++++---- .../history/AttendanceHistoryViewModel.kt | 89 ++++++++---- .../feature/attendance/punch/PunchScreen.kt | 76 ++++++++--- .../attendance/punch/PunchViewModel.kt | 19 ++- .../feature/attendance/qr/QrScanScreen.kt | 10 +- .../src/main/res/values-en/strings.xml | 39 ++++++ .../src/main/res/values-ps/strings.xml | 39 ++++++ .../src/main/res/values/strings.xml | 39 ++++++ .../app/worktrack/feature/auth/LoginScreen.kt | 38 ++++-- .../worktrack/feature/auth/LoginViewModel.kt | 18 +-- .../auth/src/main/res/values-en/strings.xml | 11 ++ .../auth/src/main/res/values-ps/strings.xml | 11 ++ feature/auth/src/main/res/values/strings.xml | 11 ++ .../feature/dashboard/DashboardScreen.kt | 61 ++++++--- .../src/main/res/values-en/strings.xml | 19 +++ .../src/main/res/values-ps/strings.xml | 19 +++ .../dashboard/src/main/res/values/strings.xml | 19 +++ .../feature/leave/apply/ApplyLeaveScreen.kt | 67 ++++++--- .../leave/apply/ApplyLeaveViewModel.kt | 22 +-- .../leave/approvals/ApprovalsScreen.kt | 57 +++++--- .../leave/approvals/ApprovalsViewModel.kt | 18 +-- .../leave/overview/LeaveOverviewScreen.kt | 86 ++++++++---- .../leave/overview/LeaveOverviewViewModel.kt | 15 +- .../leave/src/main/res/values-en/strings.xml | 51 +++++++ .../leave/src/main/res/values-ps/strings.xml | 51 +++++++ feature/leave/src/main/res/values/strings.xml | 51 +++++++ .../feature/payslips/PayslipsScreen.kt | 45 ++++-- .../feature/payslips/PayslipsViewModel.kt | 13 +- .../payslips/detail/PayslipDetailScreen.kt | 52 ++++--- .../src/main/res/values-en/strings.xml | 18 +++ .../src/main/res/values-ps/strings.xml | 18 +++ .../payslips/src/main/res/values/strings.xml | 18 +++ feature/profile/build.gradle.kts | 5 + .../feature/profile/ProfileScreen.kt | 118 ++++++++++++---- .../src/main/res/values-en/strings.xml | 34 +++++ .../src/main/res/values-ps/strings.xml | 34 +++++ .../profile/src/main/res/values/strings.xml | 35 +++++ gradle/libs.versions.toml | 2 + 61 files changed, 1795 insertions(+), 309 deletions(-) create mode 100644 app/src/main/res/values-en/strings.xml create mode 100644 app/src/main/res/values-ps/strings.xml create mode 100644 app/src/main/res/xml/locales_config.xml delete mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt create mode 100644 core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt create mode 100644 core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt create mode 100644 core/designsystem/src/main/res/values-en/strings.xml create mode 100644 core/designsystem/src/main/res/values-ps/strings.xml create mode 100644 core/designsystem/src/main/res/values/strings.xml create mode 100644 docs/10-localization-afghanistan.md create mode 100644 feature/attendance/src/main/res/values-en/strings.xml create mode 100644 feature/attendance/src/main/res/values-ps/strings.xml create mode 100644 feature/attendance/src/main/res/values/strings.xml create mode 100644 feature/auth/src/main/res/values-en/strings.xml create mode 100644 feature/auth/src/main/res/values-ps/strings.xml create mode 100644 feature/auth/src/main/res/values/strings.xml create mode 100644 feature/dashboard/src/main/res/values-en/strings.xml create mode 100644 feature/dashboard/src/main/res/values-ps/strings.xml create mode 100644 feature/dashboard/src/main/res/values/strings.xml create mode 100644 feature/leave/src/main/res/values-en/strings.xml create mode 100644 feature/leave/src/main/res/values-ps/strings.xml create mode 100644 feature/leave/src/main/res/values/strings.xml create mode 100644 feature/payslips/src/main/res/values-en/strings.xml create mode 100644 feature/payslips/src/main/res/values-ps/strings.xml create mode 100644 feature/payslips/src/main/res/values/strings.xml create mode 100644 feature/profile/src/main/res/values-en/strings.xml create mode 100644 feature/profile/src/main/res/values-ps/strings.xml create mode 100644 feature/profile/src/main/res/values/strings.xml diff --git a/README.md b/README.md index e5e03da..3b33ef6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # WorkTrack — Smart Workforce & Attendance Management -WorkTrack is a multi-tenant Workforce Management Platform (HRMS): attendance with GPS -geofencing and kiosk QR check-in, shift scheduling, leave management with approval -chains, payroll, announcements, analytics, and enterprise-grade security — designed -for organizations from small teams to 100,000+ employees. +**ورک‌ترک — مدیریت هوشمند نیروی کار برای افغانستان.** پلتفرم به زبان‌های **دری** +(پیش‌فرض) و **پښتو** و انگلیسی است؛ تاریخ‌ها و دوره‌های معاش بر اساس تقویم +**هجری شمسی** با نام ماه‌های افغانستان (حمل، ثور، جوزا…) نمایش داده می‌شود و رخصتی +هفته‌وار روز جمعه است. + +WorkTrack is a multi-tenant Workforce Management Platform (HRMS) **built for +Afghanistan**: attendance with GPS geofencing and kiosk QR check-in, shift +scheduling, leave management with approval chains, payroll, announcements, +analytics, and enterprise-grade security — designed for organizations from small +teams to 100,000+ employees. Dari is the default language (full Pashto and English +translations, RTL-first UI), and all dates/payroll periods use the Solar Hijri +calendar — see `docs/10-localization-afghanistan.md`. ## Repository layout @@ -26,6 +34,7 @@ for organizations from small teams to 100,000+ employees. 8. [Security architecture](docs/07-security-architecture.md) 9. [Offline-first sync strategy](docs/08-sync-strategy.md) 10. [Development roadmap](docs/09-roadmap.md) +11. [Afghanistan localization (دری/پښتو, Solar Hijri)](docs/10-localization-afghanistan.md) ## Android app diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd52a19..6b6f6b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(projects.core.designsystem) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 834f9c8..db5ab32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.WorkTrack"> diff --git a/app/src/main/kotlin/app/worktrack/MainActivity.kt b/app/src/main/kotlin/app/worktrack/MainActivity.kt index 1ab47da..7998542 100644 --- a/app/src/main/kotlin/app/worktrack/MainActivity.kt +++ b/app/src/main/kotlin/app/worktrack/MainActivity.kt @@ -1,15 +1,17 @@ package app.worktrack import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity import app.worktrack.core.designsystem.theme.WorkTrackTheme import app.worktrack.ui.WorkTrackApp import dagger.hilt.android.AndroidEntryPoint +// AppCompatActivity (not ComponentActivity) so AppCompatDelegate can apply the +// user's chosen app language (Dari/Pashto/English) on every API level. @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt index e3e60fc..2fd4f27 100644 --- a/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt +++ b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import app.worktrack.R import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost @@ -38,16 +40,16 @@ import app.worktrack.feature.profile.navigation.profileScreen private data class TopLevelDestination( val route: String, - val label: String, + val labelRes: Int, val selectedIcon: ImageVector, val unselectedIcon: ImageVector, ) private val topLevelDestinations = listOf( - TopLevelDestination(DASHBOARD_ROUTE, "Home", Icons.Filled.Home, Icons.Outlined.Home), - TopLevelDestination(PUNCH_ROUTE, "Attendance", Icons.Filled.Fingerprint, Icons.Outlined.Fingerprint), - TopLevelDestination(LEAVE_ROUTE, "Leave", Icons.Filled.BeachAccess, Icons.Outlined.BeachAccess), - TopLevelDestination(PROFILE_ROUTE, "Profile", Icons.Filled.Person, Icons.Outlined.Person), + TopLevelDestination(DASHBOARD_ROUTE, R.string.nav_home, Icons.Filled.Home, Icons.Outlined.Home), + TopLevelDestination(PUNCH_ROUTE, R.string.nav_attendance, Icons.Filled.Fingerprint, Icons.Outlined.Fingerprint), + TopLevelDestination(LEAVE_ROUTE, R.string.nav_leave, Icons.Filled.BeachAccess, Icons.Outlined.BeachAccess), + TopLevelDestination(PROFILE_ROUTE, R.string.nav_profile, Icons.Filled.Person, Icons.Outlined.Person), ) @Composable @@ -66,6 +68,7 @@ fun MainScaffold() { val selected = currentDestination ?.hierarchy ?.any { it.route == destination.route } == true + val label = stringResource(destination.labelRes) NavigationBarItem( selected = selected, onClick = { @@ -84,10 +87,10 @@ fun MainScaffold() { } else { destination.unselectedIcon }, - contentDescription = destination.label, + contentDescription = label, ) }, - label = { Text(destination.label) }, + label = { Text(label) }, ) } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..43141ca --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,8 @@ + + + WorkTrack + Home + Attendance + Leave + Profile + diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..ba185a8 --- /dev/null +++ b/app/src/main/res/values-ps/strings.xml @@ -0,0 +1,8 @@ + + + WorkTrack + کور + حاضري + رخصتي + پروفایل + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fed6fe..7d56417 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,9 @@ + WorkTrack + خانه + حاضری + رخصتی + پروفایل diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2e3b4cb..c991c5b 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,7 +1,8 @@ - - diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..62d0fb7 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt deleted file mode 100644 index ffea1df..0000000 --- a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppErrorMessages.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.worktrack.core.common.result - -/** - * Default English user-facing message per error. Feature UIs may override for - * screen-specific phrasing; localization replaces this in the l10n pass (P1). - */ -fun AppError.userMessage(): String = when (this) { - AppError.Network -> "You're offline. Changes are saved and will sync automatically." - AppError.Unauthenticated -> "Your session has expired. Please sign in again." - AppError.PermissionDenied -> "You don't have permission to do that." - AppError.NotFound -> "That item could not be found." - is AppError.Validation -> message - is AppError.Business -> message - is AppError.Http -> message ?: "Something went wrong on the server ($status)." - is AppError.Unexpected -> "Something went wrong. Please try again." -} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt b/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt new file mode 100644 index 0000000..9fd09d0 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt @@ -0,0 +1,128 @@ +package app.worktrack.core.common.time + +import java.time.LocalDate + +/** + * Solar Hijri (هجری شمسی) date — the official calendar of Afghanistan. + * Month 1 is Hamal/حمل (vernal equinox, ~21 March). + */ +data class SolarHijriDate(val year: Int, val month: Int, val day: Int) { + init { + require(month in 1..12) { "month must be 1..12" } + require(day in 1..31) { "day must be 1..31" } + } + + fun toGregorian(): LocalDate = SolarHijri.toGregorian(this) + + /** Sortable "1405-04" style key for one month; used for paging state. */ + fun monthKey(): String = "%04d-%02d".format(year, month) +} + +/** + * Solar Hijri <-> Gregorian conversion using the arithmetic astronomical-cycle + * algorithm from jalaali-js (Behrooz/Birashk break years), accurate for the + * years this platform will ever process (1178–1633 AP / 1799–2254 AD). + * Afghanistan shares the leap-year structure; only month names differ. + */ +object SolarHijri { + + private val BREAKS = intArrayOf( + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, + 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, + ) + + fun fromGregorian(date: LocalDate): SolarHijriDate = d2j(g2d(date.year, date.monthValue, date.dayOfMonth)) + + fun toGregorian(date: SolarHijriDate): LocalDate = d2g(j2d(date.year, date.month, date.day)) + + fun today(timeProvider: TimeProvider): SolarHijriDate = fromGregorian(timeProvider.today()) + + fun isLeapYear(year: Int): Boolean = jalCal(year).leap == 0 + + fun monthLength(year: Int, month: Int): Int = when { + month <= 6 -> 31 + month <= 11 -> 30 + else -> if (isLeapYear(year)) 30 else 29 + } + + /** First Gregorian day of a Solar Hijri month (for date-range queries). */ + fun monthStart(year: Int, month: Int): LocalDate = toGregorian(SolarHijriDate(year, month, 1)) + + fun monthEnd(year: Int, month: Int): LocalDate = + toGregorian(SolarHijriDate(year, month, monthLength(year, month))) + + // ------------------------------------------------------------ internals + + private data class JalCal(val leap: Int, val gy: Int, val march: Int) + + private fun jalCal(jy: Int): JalCal { + require(jy in (BREAKS.first() + 1) until BREAKS.last()) { "year $jy out of supported range" } + val gy = jy + 621 + var leapJ = -14 + var jp = BREAKS[0] + + var jump = 0 + for (i in 1 until BREAKS.size) { + val jm = BREAKS[i] + jump = jm - jp + if (jy < jm) break + leapJ += jump / 33 * 8 + jump % 33 / 4 + jp = jm + } + var n = jy - jp + + leapJ += n / 33 * 8 + (n % 33 + 3) / 4 + if (jump % 33 == 4 && jump - n == 4) leapJ += 1 + + val leapG = gy / 4 - (gy / 100 + 1) * 3 / 4 - 150 + val march = 20 + leapJ - leapG + + if (jump - n < 6) n = n - jump + (jump + 4) / 33 * 33 + var leap = ((n + 1) % 33 - 1) % 4 + if (leap == -1) leap = 4 + + return JalCal(leap = leap, gy = gy, march = march) + } + + private fun g2d(gy: Int, gm: Int, gd: Int): Int { + var d = (gy + (gm - 8) / 6 + 100100) * 1461 / 4 + + (153 * ((gm + 9) % 12) + 2) / 5 + gd - 34840408 + d = d - (gy + 100100 + (gm - 8) / 6) / 100 * 3 / 4 + 752 + return d + } + + private fun d2g(jdn: Int): LocalDate { + var j = 4 * jdn + 139361631 + j += (4 * jdn + 183187720) / 146097 * 3 / 4 * 4 - 3908 + val i = j % 1461 / 4 * 5 + 308 + val gd = i % 153 / 5 + 1 + val gm = i / 153 % 12 + 1 + val gy = j / 1461 - 100100 + (8 - gm) / 6 + return LocalDate.of(gy, gm, gd) + } + + private fun j2d(jy: Int, jm: Int, jd: Int): Int { + val r = jalCal(jy) + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - jm / 7 * (jm - 7) + jd - 1 + } + + private fun d2j(jdn: Int): SolarHijriDate { + val gy = d2g(jdn).year + var jy = gy - 621 + val r = jalCal(jy) + val jdn1f = g2d(gy, 3, r.march) + var k = jdn - jdn1f + + if (k >= 0) { + if (k <= 185) { + return SolarHijriDate(jy, 1 + k / 31, k % 31 + 1) + } + k -= 186 + } else { + jy -= 1 + k += 179 + if (r.leap == 1) k += 1 + } + return SolarHijriDate(jy, 7 + k / 30, k % 30 + 1) + } +} diff --git a/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt b/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt new file mode 100644 index 0000000..abe5cbb --- /dev/null +++ b/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt @@ -0,0 +1,67 @@ +package app.worktrack.core.common.time + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SolarHijriTest { + + @Test + fun `nawruz 1405 is 21 March 2026`() { + assertEquals( + SolarHijriDate(1405, 1, 1), + SolarHijri.fromGregorian(LocalDate.of(2026, 3, 21)), + ) + assertEquals( + LocalDate.of(2026, 3, 21), + SolarHijriDate(1405, 1, 1).toGregorian(), + ) + } + + @Test + fun `mid year conversion`() { + // 17 July 2026 = 26 Saratan 1405 + assertEquals( + SolarHijriDate(1405, 4, 26), + SolarHijri.fromGregorian(LocalDate.of(2026, 7, 17)), + ) + } + + @Test + fun `epoch day converts`() { + // 1 January 1970 = 11 Jadi 1348 + assertEquals( + SolarHijriDate(1348, 10, 11), + SolarHijri.fromGregorian(LocalDate.of(1970, 1, 1)), + ) + } + + @Test + fun `round trip across two full years`() { + var date = LocalDate.of(2025, 3, 1) + repeat(730) { + val shamsi = SolarHijri.fromGregorian(date) + assertEquals("round trip failed for $date", date, shamsi.toGregorian()) + date = date.plusDays(1) + } + } + + @Test + fun `leap years`() { + assertTrue(SolarHijri.isLeapYear(1403)) + assertFalse(SolarHijri.isLeapYear(1404)) + assertFalse(SolarHijri.isLeapYear(1405)) + assertEquals(30, SolarHijri.monthLength(1403, 12)) + assertEquals(29, SolarHijri.monthLength(1404, 12)) + assertEquals(31, SolarHijri.monthLength(1405, 6)) + assertEquals(30, SolarHijri.monthLength(1405, 7)) + } + + @Test + fun `month boundaries`() { + assertEquals(LocalDate.of(2026, 6, 22), SolarHijri.monthStart(1405, 4)) + assertEquals(LocalDate.of(2026, 7, 22), SolarHijri.monthEnd(1405, 4)) + } +} diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 446aa4b..5aed8bc 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -7,5 +7,6 @@ android { } dependencies { + implementation(projects.core.common) implementation(libs.androidx.compose.material.icons) } diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt index 6d60cbf..2b1b2f4 100644 --- a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt @@ -13,7 +13,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,7 +33,7 @@ fun WtTopBar( IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.ds_back), ) } } diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt index 1abebec..0e6cf5e 100644 --- a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt @@ -15,8 +15,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.R @Composable fun FullScreenLoading(modifier: Modifier = Modifier) { @@ -81,6 +83,6 @@ fun ErrorState( color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.height(16.dp)) - WtSecondaryButton(text = "Retry", onClick = onRetry) + WtSecondaryButton(text = stringResource(R.string.ds_retry), onClick = onRetry) } } diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt new file mode 100644 index 0000000..c39ed86 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt @@ -0,0 +1,113 @@ +package app.worktrack.core.designsystem.l10n + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringArrayResource +import app.worktrack.core.common.time.SolarHijri +import app.worktrack.core.common.time.SolarHijriDate +import app.worktrack.core.designsystem.R +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Afghanistan-first formatting: Solar Hijri dates with Afghan month names and + * Extended Arabic-Indic digits (۰–۹) for Dari and Pashto locales. English + * shows the same Solar Hijri dates with transliterated month names — the + * business calendar of the platform is Solar Hijri regardless of language. + */ +object AfghanDigits { + + private val EASTERN = charArrayOf('۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹') + + fun usesEasternDigits(locale: Locale): Boolean = + locale.language == "fa" || locale.language == "ps" + + fun localize(input: String, locale: Locale): String { + if (!usesEasternDigits(locale)) return input + val out = StringBuilder(input.length) + for (ch in input) { + out.append(if (ch in '0'..'9') EASTERN[ch - '0'] else ch) + } + return out.toString() + } +} + +@Composable +fun appLocale(): Locale = LocalConfiguration.current.locales[0] ?: Locale.getDefault() + +/** Converts any Latin digits in [text] to ۰–۹ for Dari/Pashto locales. */ +@Composable +fun localizedDigits(text: String): String = AfghanDigits.localize(text, appLocale()) + +@Composable +fun shamsiMonthName(month: Int): String = + stringArrayResource(R.array.ds_shamsi_months)[(month - 1).coerceIn(0, 11)] + +@Composable +fun weekdayName(date: LocalDate): String = + stringArrayResource(R.array.ds_weekdays)[date.dayOfWeek.value - 1] + +/** "پنجشنبه ۲۶ سرطان" (withWeekday) or "۲۶ سرطان ۱۴۰۵" (withYear). */ +@Composable +fun formatShamsiDate( + date: LocalDate, + withWeekday: Boolean = false, + withYear: Boolean = false, +): String { + val shamsi = SolarHijri.fromGregorian(date) + val base = buildString { + if (withWeekday) { + append(weekdayName(date)) + append(' ') + } + append(shamsi.day) + append(' ') + append(shamsiMonthName(shamsi.month)) + if (withYear) { + append(' ') + append(shamsi.year) + } + } + return localizedDigits(base) +} + +/** "سرطان ۱۴۰۵" — month header labels. */ +@Composable +fun formatShamsiMonthYear(year: Int, month: Int): String = + localizedDigits("${shamsiMonthName(month)} $year") + +/** "۲۶ سرطان – ۲ اسد" — leave/date ranges (en-dash keeps RTL ordering intact). */ +@Composable +fun formatShamsiRange(start: LocalDate, end: LocalDate): String { + val s = SolarHijri.fromGregorian(start) + val e = SolarHijri.fromGregorian(end) + val text = if (s.year == e.year && s.month == e.month && s.day == e.day) { + "${s.day} ${shamsiMonthName(s.month)} ${s.year}" + } else { + "${s.day} ${shamsiMonthName(s.month)} – ${e.day} ${shamsiMonthName(e.month)} ${e.year}" + } + return localizedDigits(text) +} + +private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") + +/** Wall-clock time in the device zone, digits localized. */ +@Composable +fun formatClockTime(instant: Instant): String = + localizedDigits(TIME_FORMAT.format(instant.atZone(ZoneId.systemDefault()))) + +/** "۲۶ سرطان ۱۴:۳۰" — compact timestamp for sync status rows. */ +@Composable +fun formatShamsiDateTime(instant: Instant): String { + val zoned = instant.atZone(ZoneId.systemDefault()) + val shamsi = SolarHijri.fromGregorian(zoned.toLocalDate()) + return localizedDigits( + "${shamsi.day} ${shamsiMonthName(shamsi.month)} ${TIME_FORMAT.format(zoned)}", + ) +} + +/** Current Solar Hijri date for "today" defaults in pickers/pagers. */ +fun shamsiToday(): SolarHijriDate = SolarHijri.fromGregorian(LocalDate.now()) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt new file mode 100644 index 0000000..694e15c --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt @@ -0,0 +1,36 @@ +package app.worktrack.core.designsystem.l10n + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import app.worktrack.core.common.result.AppError +import app.worktrack.core.designsystem.R + +/** + * Localized, user-facing message for any [AppError]. Business errors map by + * their stable code; unknown codes fall back to the server-provided detail + * (already human-readable) and finally to the generic message. + */ +fun AppError.localizedMessage(context: Context): String = when (this) { + AppError.Network -> context.getString(R.string.ds_err_network) + AppError.Unauthenticated -> context.getString(R.string.ds_err_unauthenticated) + AppError.PermissionDenied -> context.getString(R.string.ds_err_permission) + AppError.NotFound -> context.getString(R.string.ds_err_not_found) + is AppError.Validation -> context.getString(R.string.ds_err_validation) + is AppError.Business -> when (code) { + "INVALID_CREDENTIALS" -> context.getString(R.string.ds_err_invalid_credentials) + "MOCK_LOCATION" -> context.getString(R.string.ds_err_mock_location) + "GEOFENCE_VIOLATION" -> context.getString(R.string.ds_err_geofence) + "INSUFFICIENT_LEAVE_BALANCE" -> context.getString(R.string.ds_err_leave_balance) + "NOT_SYNCED" -> context.getString(R.string.ds_err_not_synced) + "KIOSK_TOKEN_INVALID" -> context.getString(R.string.ds_err_kiosk_token) + "INVALID_STATE" -> context.getString(R.string.ds_err_invalid_state) + else -> message.ifBlank { context.getString(R.string.ds_err_unexpected) } + } + + is AppError.Http -> context.getString(R.string.ds_err_server, status.toString()) + is AppError.Unexpected -> context.getString(R.string.ds_err_unexpected) +} + +@Composable +fun AppError.localizedMessage(): String = localizedMessage(LocalContext.current) diff --git a/core/designsystem/src/main/res/values-en/strings.xml b/core/designsystem/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..0687e41 --- /dev/null +++ b/core/designsystem/src/main/res/values-en/strings.xml @@ -0,0 +1,49 @@ + + + Retry + Back + OK + Cancel + + You\'re offline. Changes are saved and will sync automatically. + Your session has expired. Please sign in again. + You don\'t have permission to do that. + That item could not be found. + Something went wrong. Please try again. + A server error occurred (%1$s). + The entered information is not valid. + + Email or password is incorrect. + Mock locations are not allowed for attendance. + You are outside the allowed work area. + Your leave balance is not sufficient. + Wait for this request to finish syncing first. + The kiosk QR code is invalid; scan it again. + This request has already been finalized. + + + + Hamal + Sawr + Jawza + Saratan + Asad + Sunbula + Mizan + Aqrab + Qaws + Jadi + Dalw + Hut + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + + diff --git a/core/designsystem/src/main/res/values-ps/strings.xml b/core/designsystem/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..8e727d2 --- /dev/null +++ b/core/designsystem/src/main/res/values-ps/strings.xml @@ -0,0 +1,48 @@ + + + بیا هڅه + شاته + سمه ده + لغوه + + تاسو آفلاین یاست. بدلونونه خوندي شول او په خپلکاره توګه به همغږي شي. + ستاسو ناسته پای ته ورسېده. مهرباني وکړئ بیا ننوځئ. + تاسو د دې کار اجازه نه لرئ. + توکی ونه موندل شو. + ستونزه رامنځته شوه. مهرباني وکړئ بیا هڅه وکړئ. + د سرور تېروتنه وشوه (%1$s). + ورکړل شوي معلومات سم نه دي. + + برېښنالیک یا پټنوم سم نه دی. + جعلي موقعیت د حاضرۍ لپاره مجاز نه دی. + تاسو د مجازې کاري ساحې بهر یاست. + ستاسو د رخصتۍ بیلانس بسنه نه کوي. + صبر وکړئ چې دا غوښتنه لومړی همغږي شي. + د کیوسک QR کوډ سم نه دی؛ بیا یې سکن کړئ. + دا غوښتنه له مخکې پای ته رسېدلې ده. + + + وری + غويی + غبرګولی + چنګاښ + زمری + وږی + تله + لړم + ليندۍ + مرغومی + سلواغه + کب + + + + دوشنبه + درېشنبه + څلورشنبه + پينځشنبه + جمعه + شنبه + یکشنبه + + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml new file mode 100644 index 0000000..490a35d --- /dev/null +++ b/core/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ + + + + تلاش دوباره + بازگشت + تایید + لغو + + آفلاین هستید. تغییرات ذخیره شد و به صورت خودکار همگام می‌شود. + نشست شما پایان یافته است. لطفاً دوباره وارد شوید. + اجازهٔ این کار را ندارید. + مورد موردنظر یافت نشد. + مشکلی پیش آمد. لطفاً دوباره تلاش کنید. + خطای سرور رخ داد (%1$s). + معلومات واردشده درست نیست. + + ایمیل یا رمز عبور نادرست است. + استفاده از موقعیت جعلی برای حاضری مجاز نیست. + شما خارج از ساحهٔ کاری مجاز هستید. + بیلانس رخصتی شما کافی نیست. + صبر کنید تا این درخواست اول همگام شود. + کود QR کیوسک معتبر نیست؛ دوباره اسکن کنید. + این درخواست قبلاً نهایی شده است. + + + + حمل + ثور + جوزا + سرطان + اسد + سنبله + میزان + عقرب + قوس + جدی + دلو + حوت + + + + + دوشنبه + سه‌شنبه + چهارشنبه + پنجشنبه + جمعه + شنبه + یکشنبه + + diff --git a/docs/00-master-spec.md b/docs/00-master-spec.md index 6517c53..7726ce4 100644 --- a/docs/00-master-spec.md +++ b/docs/00-master-spec.md @@ -10,7 +10,12 @@ Version: 1.0 · Status: Approved · Owners: Platform Architecture ## 1. Product definition -WorkTrack is a multi-tenant Workforce Management Platform (HRMS) covering: +WorkTrack is a multi-tenant Workforce Management Platform (HRMS) **built for +Afghanistan**: Dari (دری) is the default product language with full Pashto (پښتو) +and English translations, all dates and payroll periods follow the Solar Hijri +(هجری شمسی) calendar with Afghan month names, the weekend is Friday, and defaults +are AFN currency and the Asia/Kabul timezone. See `10-localization-afghanistan.md` +for the full localization architecture. The platform covers: | Domain | Capabilities | |---|---| diff --git a/docs/10-localization-afghanistan.md b/docs/10-localization-afghanistan.md new file mode 100644 index 0000000..5885304 --- /dev/null +++ b/docs/10-localization-afghanistan.md @@ -0,0 +1,97 @@ +# WorkTrack — Afghanistan Localization Architecture (دری / پښتو) + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + +WorkTrack is built **for Afghanistan and Afghan organizations**. Dari (دری) and +Pashto (پښتو) are first-class product languages — not translations bolted onto an +English app — and the platform's business calendar is the **Solar Hijri (هجری شمسی)** +calendar. This document specifies how that is implemented across the Android app, +backend, and (future) web admin. + +--- + +## 1. Language policy + +| Locale | Role | +|---|---| +| `fa-AF` (Dari) | **Default.** The base `values/` resources are Dari; any unmatched device locale falls back to Dari. | +| `ps-AF` (Pashto) | Full translation (`values-ps/`). | +| `en` | Full translation (`values-en/`) for foreign managers/auditors. | + +- Every user-visible string lives in per-module resources with a module prefix + (`ds_`, `auth_`, `dash_`, `att_`, `leave_`, `pay_`, `prof_`, `nav_`) so library + resource merging can never silently collide. +- The brand name "WorkTrack" stays in Latin script in all languages. +- `android:localeConfig` (`app/src/main/res/xml/locales_config.xml`) surfaces the + per-app language setting on Android 13+; the in-app picker in **Profile → زبان** + uses `AppCompatDelegate.setApplicationLocales` and works on every supported API + level (`MainActivity` extends `AppCompatActivity` for exactly this reason). + +## 2. Error and message localization + +- Screen strings resolve via `stringResource` per module. +- Domain/server failures travel as typed `AppError` values (never pre-rendered + strings). ViewModels emit `AppError` in state/effects; the UI renders it with + `AppError.localizedMessage()` (`core:designsystem/l10n/ErrorMessages.kt`), which + maps stable business codes (`GEOFENCE_VIOLATION`, `INSUFFICIENT_LEAVE_BALANCE`, + `KIOSK_TOKEN_INVALID`, …) to Dari/Pashto/English text. +- Field-level validation surfaces as **field keys**; each screen maps keys to its + own localized messages, so no English validation text leaks from the domain layer. + +## 3. Calendar: Solar Hijri everywhere + +- `core:common/time/SolarHijri.kt` implements Gregorian ⇄ Solar Hijri conversion + (ported from the jalaali-js break-year algorithm, unit-tested incl. round trips + and leap years). Afghanistan shares the Iranian leap structure; only month names + differ. +- Afghan month names ship as localized string arrays: Dari **حمل ثور جوزا سرطان اسد + سنبله میزان عقرب قوس جدی دلو حوت**, Pashto **وری غويی غبرګولی چنګاښ زمری وږی تله + لړم ليندۍ مرغومی سلواغه کب**, English transliterations for the `en` locale. +- Display formatting is centralized in `core:designsystem/l10n/AfghanFormat.kt`: + dates, ranges, month headers, and timestamps all render in Shamsi with + Extended Arabic-Indic digits (۰–۹) for Dari/Pashto. +- **Attendance history pages by Shamsi month** (e.g. سرطان ۱۴۰۵): the ViewModel + converts the Shamsi month to a Gregorian date range for the Room query. +- **Payroll periods are Shamsi months**: `Payslip.periodYear/periodMonth` carry + Solar Hijri values (1405/4 = سرطان ۱۴۰۵). Tenant provisioning and the payroll + engine (P2) must create runs per Shamsi month. +- Storage stays Gregorian/epoch-based (Room, Firestore, API ISO-8601): the + conversion happens only at the display and query-boundary layers, which keeps + interop, indexes, and delta cursors calendar-agnostic. + +## 4. RTL, digits, typography + +- `supportsRtl` + Compose's locale-driven `LayoutDirection` mirror every screen; + directional icons use the `AutoMirrored` icon set. +- Digits: Latin digits are converted to ۰–۹ at display time (`AfghanDigits`) for + `fa`/`ps`. Data entry and storage remain ASCII. +- System fonts cover Arabic-script Dari/Pashto (incl. ګ ډ ړ ږ ۍ ...). A custom + Vazirmatn/Noto Naskh bundle is a P1 polish item. + +## 5. Afghanistan business rules + +| Rule | Where | +|---|---| +| Weekend = **Friday** (جمعه) | Server holiday calendars mark Friday `WEEK_OFF`; leave settlement excludes Fridays and public holidays on approval (client shows an estimate note). | +| Public holidays (Nawruz, Eid al-Fitr, Eid al-Adha, Ashura, Independence Day…) | Tenant `HolidayCalendar` seeded per year; Eid dates are lunar and entered per-tenant annually. | +| Currency | Default `AFN` (؋); stored per company, formatted with localized digits. | +| Timezone | Default `Asia/Kabul` (UTC+4:30) per company/branch. | +| Payroll | Runs per Solar Hijri month (§3). | + +## 6. Testing & workflow + +- `SolarHijriTest` covers Nawruz boundaries, leap years (1403 leap / 1404–05 not), + month lengths, and 730-day round trips. +- Translation source of truth is the resource files; new strings must land in all + three locales in the same PR (enforceable via `lint missingTranslation` once the + default-locale declaration `tools:locale="fa"` is added in a lint pass). +- Web admin (P3) reuses the same message catalogs via exported JSON. + +## 7. Known gaps (tracked for P1) + +- Material date picker still renders a Gregorian grid; a native Shamsi picker + component is a P1 deliverable (selected dates already display in Shamsi). +- Server-generated `detail` strings inside RFC 7807 problems are English; clients + render localized text by `code`, so this only affects debugging surfaces. +- Pashto plural forms are simplified (Android quantity strings to be adopted with + the l10n lint pass). diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt index 80df276..db98b3d 100644 --- a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,11 +31,12 @@ import app.worktrack.core.designsystem.component.ChipTone import app.worktrack.core.designsystem.component.EmptyState import app.worktrack.core.designsystem.component.StatusChip import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiDate +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits import app.worktrack.core.model.AttendanceDay import app.worktrack.core.model.AttendanceDayStatus -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale +import app.worktrack.feature.attendance.R @Composable fun AttendanceHistoryRoute( @@ -43,13 +46,11 @@ fun AttendanceHistoryRoute( val state by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( - topBar = { WtTopBar(title = "Attendance history", onBack = onBack) }, + topBar = { WtTopBar(title = stringResource(R.string.att_history_title), onBack = onBack) }, ) { padding -> Column(Modifier.padding(padding)) { MonthSelector( - label = "${ - state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) - } ${state.month.year}", + label = formatShamsiMonthYear(state.shamsiYear, state.shamsiMonth), canGoForward = state.canGoForward, onPrevious = viewModel::onPreviousMonth, onNext = viewModel::onNextMonth, @@ -57,8 +58,8 @@ fun AttendanceHistoryRoute( if (state.days.isEmpty()) { EmptyState( icon = Icons.Filled.EventBusy, - title = "No records", - message = "Attendance for this month appears here after your first sync.", + title = stringResource(R.string.att_history_empty_title), + message = stringResource(R.string.att_history_empty_msg), ) } else { LazyColumn( @@ -86,16 +87,22 @@ private fun MonthSelector( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onPrevious) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous month") + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.att_prev_month), + ) } Text( text = label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) IconButton(onClick = onNext, enabled = canGoForward) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next month") + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.att_next_month), + ) } } } @@ -113,20 +120,38 @@ private fun DayCard(day: AttendanceDay) { ) { Column(Modifier.weight(1f)) { Text( - text = day.date.format(DateTimeFormatter.ofPattern("EEE, d MMM")), + text = formatShamsiDate(day.date, withWeekday = true), style = MaterialTheme.typography.titleSmall, ) if (day.workedMinutes > 0) { + val worked = localizedDigits( + stringResource( + R.string.att_worked_short, + (day.workedMinutes / 60).toString(), + (day.workedMinutes % 60).toString(), + ), + ) + val overtime = if (day.overtimeMinutes > 0) { + " · " + localizedDigits( + stringResource( + R.string.att_overtime_short, + day.overtimeMinutes.toString(), + ), + ) + } else { + "" + } Text( - text = "Worked ${day.workedMinutes / 60}h ${day.workedMinutes % 60}m" + - if (day.overtimeMinutes > 0) " · OT ${day.overtimeMinutes}m" else "", + text = worked + overtime, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (day.lateMinutes > 0) { Text( - text = "Late by ${day.lateMinutes}m", + text = localizedDigits( + stringResource(R.string.att_late_by, day.lateMinutes.toString()), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -137,15 +162,18 @@ private fun DayCard(day: AttendanceDay) { } } -private fun AttendanceDayStatus.label(): String = when (this) { - AttendanceDayStatus.PRESENT -> "Present" - AttendanceDayStatus.ABSENT -> "Absent" - AttendanceDayStatus.HALF_DAY -> "Half day" - AttendanceDayStatus.LEAVE -> "Leave" - AttendanceDayStatus.HOLIDAY -> "Holiday" - AttendanceDayStatus.WEEK_OFF -> "Week off" - AttendanceDayStatus.PENDING -> "Pending" -} +@Composable +private fun AttendanceDayStatus.label(): String = stringResource( + when (this) { + AttendanceDayStatus.PRESENT -> R.string.att_status_present + AttendanceDayStatus.ABSENT -> R.string.att_status_absent + AttendanceDayStatus.HALF_DAY -> R.string.att_status_half_day + AttendanceDayStatus.LEAVE -> R.string.att_status_leave + AttendanceDayStatus.HOLIDAY -> R.string.att_status_holiday + AttendanceDayStatus.WEEK_OFF -> R.string.att_status_week_off + AttendanceDayStatus.PENDING -> R.string.att_status_pending + }, +) private fun AttendanceDayStatus.tone(): ChipTone = when (this) { AttendanceDayStatus.PRESENT -> ChipTone.POSITIVE diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt index 5dc4677..1e92177 100644 --- a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt @@ -3,72 +3,101 @@ package app.worktrack.feature.attendance.history import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.time.SolarHijri +import app.worktrack.core.common.time.SolarHijriDate import app.worktrack.core.common.time.TimeProvider -import app.worktrack.core.domain.usecase.attendance.ObserveAttendanceHistoryUseCase +import app.worktrack.core.domain.repository.AttendanceRepository import app.worktrack.core.model.AttendanceDay import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.YearMonth import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +/** + * Attendance history paged by **Solar Hijri** months — the business calendar + * of the platform. The Room query range is the Gregorian projection of the + * selected Shamsi month. + */ data class AttendanceHistoryUiState( - val month: YearMonth, + val shamsiYear: Int, + val shamsiMonth: Int, val days: List, val canGoForward: Boolean, ) @HiltViewModel class AttendanceHistoryViewModel @Inject constructor( - observeHistory: ObserveAttendanceHistoryUseCase, + attendanceRepository: AttendanceRepository, private val timeProvider: TimeProvider, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - // Persist the selected month across process death (survives low-memory kills). - private val month: StateFlow = savedStateHandle.getStateFlow( + private fun currentShamsiMonth(): SolarHijriDate = SolarHijri.today(timeProvider) + + // "1405-04" — survives process death. + private val monthKey: StateFlow = savedStateHandle.getStateFlow( KEY_MONTH, - YearMonth.from(timeProvider.today()).toString(), + currentShamsiMonth().monthKey(), ) - val uiState: StateFlow = month - .map(YearMonth::parse) - .flatMapLatest { selected -> - observeHistory(selected).map { days -> selected to days } - } - .combine(month) { (selected, days), _ -> - AttendanceHistoryUiState( - month = selected, - days = days, - canGoForward = selected < YearMonth.from(timeProvider.today()), - ) + val uiState: StateFlow = monthKey + .map(::parseKey) + .flatMapLatest { (year, month) -> + attendanceRepository + .observeDays( + from = SolarHijri.monthStart(year, month), + to = SolarHijri.monthEnd(year, month), + ) + .map { days -> + val today = currentShamsiMonth() + AttendanceHistoryUiState( + shamsiYear = year, + shamsiMonth = month, + days = days, + canGoForward = year < today.year || + (year == today.year && month < today.month), + ) + } } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = AttendanceHistoryUiState( - month = YearMonth.parse(month.value), - days = emptyList(), - canGoForward = false, - ), + initialValue = parseKey(monthKey.value).let { (year, month) -> + AttendanceHistoryUiState(year, month, emptyList(), canGoForward = false) + }, ) fun onPreviousMonth() = shiftMonth(-1) fun onNextMonth() = shiftMonth(+1) - private fun shiftMonth(delta: Long) { - val current = YearMonth.parse(month.value) - val target = current.plusMonths(delta) - if (target > YearMonth.from(timeProvider.today())) return - savedStateHandle[KEY_MONTH] = target.toString() + private fun shiftMonth(delta: Int) { + val (year, month) = parseKey(monthKey.value) + var targetYear = year + var targetMonth = month + delta + if (targetMonth < 1) { + targetMonth = 12 + targetYear -= 1 + } else if (targetMonth > 12) { + targetMonth = 1 + targetYear += 1 + } + val today = currentShamsiMonth() + val beyondCurrent = targetYear > today.year || + (targetYear == today.year && targetMonth > today.month) + if (beyondCurrent) return + savedStateHandle[KEY_MONTH] = SolarHijriDate(targetYear, targetMonth, 1).monthKey() + } + + private fun parseKey(key: String): Pair { + val (year, month) = key.split("-").map(String::toInt) + return year to month } private companion object { - const val KEY_MONTH = "month" + const val KEY_MONTH = "shamsiMonth" } } diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt index 794b4e3..4e8ed78 100644 --- a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -36,7 +37,10 @@ import app.worktrack.core.designsystem.component.StatusChip import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtSecondaryButton import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage import app.worktrack.core.model.PunchType +import app.worktrack.feature.attendance.R @Composable fun PunchRoute( @@ -70,20 +74,24 @@ fun PunchRoute( LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { - is PunchEffect.Message -> snackbarHostState.showSnackbar(effect.text) + is PunchEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + is PunchEffect.PunchRecorded -> snackbarHostState.showSnackbar( - if (effect.type == PunchType.IN) { - "Clocked in — will sync automatically" - } else { - "Clocked out — will sync automatically" - }, + context.getString( + if (effect.type == PunchType.IN) { + R.string.att_punch_in_recorded + } else { + R.string.att_punch_out_recorded + }, + ), ) } } } Scaffold( - topBar = { WtTopBar(title = "Attendance punch", onBack = onBack) }, + topBar = { WtTopBar(title = stringResource(R.string.att_punch_title), onBack = onBack) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> PunchScreen( @@ -120,7 +128,7 @@ internal fun PunchScreen( Spacer(Modifier.height(32.dp)) WtPrimaryButton( - text = if (clockedIn) "Clock out" else "Clock in", + text = stringResource(if (clockedIn) R.string.att_clock_out else R.string.att_clock_in), onClick = onPunch, modifier = Modifier.fillMaxWidth(), enabled = state.location is LocationUiState.Ready, @@ -130,7 +138,7 @@ internal fun PunchScreen( Spacer(Modifier.height(16.dp)) WtSecondaryButton( - text = "Scan kiosk QR instead", + text = stringResource(R.string.att_scan_qr), onClick = onScanQr, modifier = Modifier.fillMaxWidth(), ) @@ -149,30 +157,53 @@ private fun LocationStatusCard( ) { when (location) { LocationUiState.PermissionRequired -> { - Text("Location permission needed", style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.att_location_permission_needed), + style = MaterialTheme.typography.titleMedium, + ) } LocationUiState.Acquiring -> { - Text("Getting your location…", style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.att_getting_location), + style = MaterialTheme.typography.titleMedium, + ) } is LocationUiState.Ready -> { val evaluation = location.evaluation when { - !evaluation.fencesConfigured -> StatusChip("No geofence required", ChipTone.NEUTRAL) + !evaluation.fencesConfigured -> StatusChip( + stringResource(R.string.att_no_geofence), + ChipTone.NEUTRAL, + ) + evaluation.insideFence -> StatusChip( - "Inside ${evaluation.nearestFence?.name ?: "work area"}", + stringResource( + R.string.att_inside_fence, + evaluation.nearestFence?.name.orEmpty(), + ), ChipTone.POSITIVE, ) else -> StatusChip( - "Outside work area (${evaluation.distanceMeters?.toInt() ?: "?"} m away)", + localizedDigits( + stringResource( + R.string.att_outside_fence, + (evaluation.distanceMeters?.toInt() ?: 0).toString(), + ), + ), ChipTone.NEGATIVE, ) } Spacer(Modifier.height(8.dp)) Text( - text = "Accuracy ±${location.location.accuracyMeters.toInt()} m", + text = localizedDigits( + stringResource( + R.string.att_accuracy, + location.location.accuracyMeters.toInt().toString(), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -180,12 +211,23 @@ private fun LocationStatusCard( is LocationUiState.Unavailable -> { Text( - text = location.reason, + text = stringResource( + when (location.reason) { + LocationUnavailableReason.PERMISSION_DENIED -> + R.string.att_loc_unavailable_permission + + LocationUnavailableReason.NO_FIX -> + R.string.att_loc_unavailable_no_fix + }, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.height(8.dp)) - WtSecondaryButton(text = "Retry", onClick = onRetry) + WtSecondaryButton( + text = stringResource(app.worktrack.core.designsystem.R.string.ds_retry), + onClick = onRetry, + ) } } Spacer(Modifier.height(8.dp)) diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt index 0a539c5..34b71b1 100644 --- a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt @@ -5,8 +5,8 @@ import androidx.annotation.RequiresPermission import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult -import app.worktrack.core.common.result.userMessage import app.worktrack.core.domain.usecase.attendance.EvaluateGeofenceUseCase import app.worktrack.core.domain.usecase.attendance.GeofenceEvaluation import app.worktrack.core.domain.usecase.attendance.ObserveTodayAttendanceUseCase @@ -29,6 +29,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +enum class LocationUnavailableReason { PERMISSION_DENIED, NO_FIX } + sealed interface LocationUiState { data object PermissionRequired : LocationUiState data object Acquiring : LocationUiState @@ -37,7 +39,7 @@ sealed interface LocationUiState { val evaluation: GeofenceEvaluation, ) : LocationUiState - data class Unavailable(val reason: String) : LocationUiState + data class Unavailable(val reason: LocationUnavailableReason) : LocationUiState } data class PunchUiState( @@ -46,7 +48,8 @@ data class PunchUiState( ) sealed interface PunchEffect { - data class Message(val text: String) : PunchEffect + /** Localized by the UI via AppError.localizedMessage(). */ + data class Failed(val error: AppError) : PunchEffect data class PunchRecorded(val type: PunchType) : PunchEffect } @@ -95,9 +98,7 @@ class PunchViewModel @Inject constructor( if (location == null) { _uiState.update { it.copy( - location = LocationUiState.Unavailable( - "Couldn't get a GPS fix. Move somewhere with a clearer view of the sky and retry.", - ), + location = LocationUiState.Unavailable(LocationUnavailableReason.NO_FIX), ) } } else { @@ -111,9 +112,7 @@ class PunchViewModel @Inject constructor( fun onLocationPermissionDenied() { _uiState.update { it.copy( - location = LocationUiState.Unavailable( - "Location permission is required for GPS punch. Use kiosk QR instead.", - ), + location = LocationUiState.Unavailable(LocationUnavailableReason.PERMISSION_DENIED), ) } } @@ -158,7 +157,7 @@ class PunchViewModel @Inject constructor( _effects.send(PunchEffect.PunchRecorded(command.type)) is AppResult.Failure -> - _effects.send(PunchEffect.Message(result.error.userMessage())) + _effects.send(PunchEffect.Failed(result.error)) } _uiState.update { it.copy(isPunching = false) } } diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt index 8e2e50e..8f86377 100644 --- a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import app.worktrack.feature.attendance.R import androidx.core.content.ContextCompat import app.worktrack.core.designsystem.component.EmptyState import app.worktrack.core.designsystem.component.WtTopBar @@ -63,7 +65,7 @@ fun QrScanRoute( } Scaffold( - topBar = { WtTopBar(title = "Scan kiosk QR", onBack = onBack) }, + topBar = { WtTopBar(title = stringResource(R.string.att_qr_title), onBack = onBack) }, ) { padding -> if (hasCameraPermission) { CameraQrScanner( @@ -75,8 +77,8 @@ fun QrScanRoute( } else { EmptyState( icon = Icons.Filled.NoPhotography, - title = "Camera permission needed", - message = "Allow camera access to scan the kiosk QR code.", + title = stringResource(R.string.att_qr_camera_permission_title), + message = stringResource(R.string.att_qr_camera_permission_msg), modifier = Modifier.padding(padding), ) } @@ -140,7 +142,7 @@ private fun CameraQrScanner( }, ) Text( - text = "Point the camera at the kiosk screen", + text = stringResource(R.string.att_qr_hint), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(16.dp), ) diff --git a/feature/attendance/src/main/res/values-en/strings.xml b/feature/attendance/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..ed4254f --- /dev/null +++ b/feature/attendance/src/main/res/values-en/strings.xml @@ -0,0 +1,39 @@ + + + Attendance punch + Location permission is required for GPS punch + Getting your location… + No work area configured — you can punch from anywhere + Inside %1$s + Outside the work area (%1$s m away) + Accuracy ±%1$s m + Scan kiosk QR + Clock in + Clock out + Clocked in — will sync automatically + Clocked out — will sync automatically + GPS punch needs location permission. Use the kiosk QR instead. + Couldn\'t get a GPS fix. Move somewhere more open and retry. + + Scan kiosk QR + Camera permission needed + Allow camera access to scan the kiosk QR code. + Point the camera at the kiosk screen + + Attendance history + No records + Attendance for this month appears here after your first sync. + Previous month + Next month + Worked %1$sh %2$sm + Overtime %1$sm + Late by %1$sm + + Present + Absent + Half day + Leave + Public holiday + Week off + Pending + diff --git a/feature/attendance/src/main/res/values-ps/strings.xml b/feature/attendance/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..c2f92cb --- /dev/null +++ b/feature/attendance/src/main/res/values-ps/strings.xml @@ -0,0 +1,39 @@ + + + د حاضرۍ ثبت + د GPS حاضرۍ لپاره د موقعیت اجازه اړینه ده + ستاسو موقعیت ترلاسه کېږي… + کاري ساحه نه ده ټاکل شوې — له هر ځایه حاضري ثبتولی شئ + د %1$s دننه + له کاري ساحې بهر (%1$s متره لرې) + کره‌والی ±%1$s متره + د کیوسک QR سکن کړئ + ورتګ ثبت کړئ + وتل ثبت کړئ + ورتګ ثبت شو — په خپلکاره توګه همغږي کېږي + وتل ثبت شول — په خپلکاره توګه همغږي کېږي + پرته له موقعیت اجازې GPS حاضري نه کېږي. د کیوسک QR وکاروئ. + GPS موقعیت ترلاسه نه شو. خلاصې فضا ته ولاړ شئ او بیا هڅه وکړئ. + + د کیوسک QR سکن + د کامرې اجازه اړینه ده + د کیوسک QR کوډ سکن لپاره کامرې ته اجازه ورکړئ. + کامره د کیوسک پردې ته ونیسئ + + د حاضرۍ تاریخچه + ریکارډ نشته + د دې میاشتې حاضري د لومړي همغږي کولو وروسته ښکاري. + پخوانۍ میاشت + راتلونکې میاشت + کار %1$s ساعته %2$s دقیقې + اضافه کار %1$s دقیقې + %1$s دقیقې ناوخته + + حاضر + غیرحاضر + نیمه ورځ + رخصتي + عمومي رخصتي + اونیزه رخصتي + په تمه + diff --git a/feature/attendance/src/main/res/values/strings.xml b/feature/attendance/src/main/res/values/strings.xml new file mode 100644 index 0000000..5ac53e6 --- /dev/null +++ b/feature/attendance/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ + + + ثبت حاضری + برای حاضری GPS اجازهٔ موقعیت لازم است + در حال دریافت موقعیت شما… + محدودهٔ کاری تعریف نشده — از هر جا می‌توانید حاضری بزنید + داخل %1$s + خارج از ساحهٔ کاری (%1$s متر فاصله) + دقت ±%1$s متر + اسکن QR کیوسک + ثبت ورود + ثبت خروج + ورود ثبت شد — به صورت خودکار همگام می‌شود + خروج ثبت شد — به صورت خودکار همگام می‌شود + بدون اجازهٔ موقعیت، حاضری GPS ممکن نیست. از QR کیوسک استفاده کنید. + موقعیت GPS دریافت نشد. به جای بازتر بروید و دوباره تلاش کنید. + + اسکن QR کیوسک + اجازهٔ کمره لازم است + برای اسکن کود QR کیوسک، اجازهٔ کمره را بدهید. + کمره را به سوی صفحهٔ کیوسک بگیرید + + تاریخچهٔ حاضری + ریکاردی نیست + حاضری این ماه بعد از اولین همگام‌سازی نمایش داده می‌شود. + ماه قبلی + ماه بعدی + کارکرد %1$s ساعت %2$s دقیقه + اضافه‌کاری %1$s دقیقه + %1$s دقیقه ناوقت + + حاضر + غیرحاضر + نیم روز + رخصتی + رخصتی عمومی + رخصتی هفته‌وار + در انتظار + diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt index 7a1d129..23e9143 100644 --- a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -30,6 +31,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.l10n.localizedMessage @Composable fun LoginRoute(viewModel: LoginViewModel = hiltViewModel()) { @@ -66,7 +68,7 @@ internal fun LoginScreen( color = MaterialTheme.colorScheme.primary, ) Text( - text = "Smart workforce management", + text = stringResource(R.string.auth_tagline), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -75,18 +77,26 @@ internal fun LoginScreen( WtTextField( value = state.email, onValueChange = onEmailChange, - label = "Work email", + label = stringResource(R.string.auth_email), modifier = Modifier.fillMaxWidth(), - errorText = state.fieldErrors["email"], + errorText = if ("email" in state.fieldErrors) { + stringResource(R.string.auth_email_invalid) + } else { + null + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), ) Spacer(Modifier.height(16.dp)) WtTextField( value = state.password, onValueChange = onPasswordChange, - label = "Password", + label = stringResource(R.string.auth_password), modifier = Modifier.fillMaxWidth(), - errorText = state.fieldErrors["password"], + errorText = if ("password" in state.fieldErrors) { + stringResource(R.string.auth_password_short) + } else { + null + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = if (state.passwordVisible) { VisualTransformation.None @@ -101,20 +111,22 @@ internal fun LoginScreen( } else { Icons.Filled.Visibility }, - contentDescription = if (state.passwordVisible) { - "Hide password" - } else { - "Show password" - }, + contentDescription = stringResource( + if (state.passwordVisible) { + R.string.auth_hide_password + } else { + R.string.auth_show_password + }, + ), ) } }, ) - state.errorMessage?.let { message -> + state.error?.let { error -> Spacer(Modifier.height(12.dp)) Text( - text = message, + text = error.localizedMessage(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, ) @@ -122,7 +134,7 @@ internal fun LoginScreen( Spacer(Modifier.height(24.dp)) WtPrimaryButton( - text = "Sign in", + text = stringResource(R.string.auth_sign_in), onClick = onSubmit, modifier = Modifier.fillMaxWidth(), loading = state.isSubmitting, diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt index ca4f0c4..c7705a8 100644 --- a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult -import app.worktrack.core.common.result.userMessage import app.worktrack.core.domain.usecase.auth.SignInUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -19,8 +18,9 @@ data class LoginUiState( val password: String = "", val passwordVisible: Boolean = false, val isSubmitting: Boolean = false, - val fieldErrors: Map = emptyMap(), - val errorMessage: String? = null, + /** Field keys with validation problems; the UI maps keys to localized text. */ + val fieldErrors: Set = emptySet(), + val error: AppError? = null, ) /** @@ -36,11 +36,11 @@ class LoginViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() fun onEmailChange(value: String) { - _uiState.update { it.copy(email = value, fieldErrors = it.fieldErrors - "email", errorMessage = null) } + _uiState.update { it.copy(email = value, fieldErrors = it.fieldErrors - "email", error = null) } } fun onPasswordChange(value: String) { - _uiState.update { it.copy(password = value, fieldErrors = it.fieldErrors - "password", errorMessage = null) } + _uiState.update { it.copy(password = value, fieldErrors = it.fieldErrors - "password", error = null) } } fun onTogglePasswordVisibility() { @@ -50,16 +50,18 @@ class LoginViewModel @Inject constructor( fun onSubmit() { val state = _uiState.value if (state.isSubmitting) return - _uiState.update { it.copy(isSubmitting = true, errorMessage = null, fieldErrors = emptyMap()) } + _uiState.update { it.copy(isSubmitting = true, error = null, fieldErrors = emptySet()) } viewModelScope.launch { when (val result = signIn(state.email, state.password)) { is AppResult.Success -> _uiState.update { it.copy(isSubmitting = false) } is AppResult.Failure -> _uiState.update { + val validation = result.error as? AppError.Validation it.copy( isSubmitting = false, - fieldErrors = (result.error as? AppError.Validation)?.fieldErrors.orEmpty(), - errorMessage = result.error.userMessage(), + fieldErrors = validation?.fieldErrors?.keys.orEmpty(), + // Field-level problems are surfaced inline, not as a banner. + error = if (validation == null) result.error else null, ) } } diff --git a/feature/auth/src/main/res/values-en/strings.xml b/feature/auth/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..523c1df --- /dev/null +++ b/feature/auth/src/main/res/values-en/strings.xml @@ -0,0 +1,11 @@ + + + Smart workforce management for Afghanistan + Work email + Password + Sign in + Show password + Hide password + Enter a valid email address + Password must be at least 8 characters + diff --git a/feature/auth/src/main/res/values-ps/strings.xml b/feature/auth/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..e4eb772 --- /dev/null +++ b/feature/auth/src/main/res/values-ps/strings.xml @@ -0,0 +1,11 @@ + + + د افغانستان لپاره د کاري ځواک هوښیار مدیریت + کاري برېښنالیک + پټنوم + ننوتل + پټنوم ښکاره کړئ + پټنوم پټ کړئ + سم برېښنالیک ولیکئ + پټنوم باید لږ تر لږه ۸ توري وي + diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml new file mode 100644 index 0000000..71a2f9c --- /dev/null +++ b/feature/auth/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + مدیریت هوشمند نیروی کار برای افغانستان + ایمیل کاری + رمز عبور + ورود + نمایش رمز عبور + پنهان کردن رمز عبور + یک ایمیل معتبر درج کنید + رمز عبور باید حداقل ۸ حرف باشد + diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt index d86f6d6..d0c22c2 100644 --- a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,12 +30,12 @@ import app.worktrack.core.designsystem.component.SectionHeader import app.worktrack.core.designsystem.component.StatusChip import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.l10n.formatClockTime +import app.worktrack.core.designsystem.l10n.localizedDigits import app.worktrack.core.domain.usecase.dashboard.DashboardSnapshot import app.worktrack.core.model.Announcement import app.worktrack.core.model.AnnouncementPriority import app.worktrack.core.model.LeaveBalance -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun DashboardRoute( @@ -66,7 +67,10 @@ internal fun DashboardScreen( item { Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Text( - text = "Hello, ${snapshot.session.displayName.substringBefore(' ')}", + text = stringResource( + R.string.dash_greeting, + snapshot.session.displayName.substringBefore(' '), + ), style = MaterialTheme.typography.headlineSmall, ) Text( @@ -86,12 +90,12 @@ internal fun DashboardScreen( } if (snapshot.leaveBalances.isNotEmpty()) { - item { SectionHeader("Leave balances") } + item { SectionHeader(stringResource(R.string.dash_leave_balances)) } item { BalancesRow(snapshot.leaveBalances) } } if (snapshot.announcements.isNotEmpty()) { - item { SectionHeader("Announcements") } + item { SectionHeader(stringResource(R.string.dash_announcements)) } items(snapshot.announcements, key = { it.id }) { announcement -> AnnouncementCard(announcement) } @@ -120,26 +124,34 @@ private fun TodayCard( Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text( - text = if (today.clockedIn) "Clocked in" else "Not clocked in", + text = stringResource( + if (today.clockedIn) R.string.dash_clocked_in else R.string.dash_not_clocked_in, + ), style = MaterialTheme.typography.titleMedium, ) - val timeFormat = DateTimeFormatter.ofPattern("HH:mm") - val zone = ZoneId.systemDefault() today.firstInAt?.let { Text( - text = "First in ${timeFormat.format(it.atZone(zone))}", + text = stringResource(R.string.dash_first_in, formatClockTime(it)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( - text = "Worked ${today.workedMinutesSoFar / 60}h ${today.workedMinutesSoFar % 60}m", + text = localizedDigits( + stringResource( + R.string.dash_worked, + (today.workedMinutesSoFar / 60).toString(), + (today.workedMinutesSoFar % 60).toString(), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } StatusChip( - text = if (today.clockedIn) "IN" else "OUT", + text = stringResource( + if (today.clockedIn) R.string.dash_chip_in else R.string.dash_chip_out, + ), tone = if (today.clockedIn) ChipTone.POSITIVE else ChipTone.NEUTRAL, ) } @@ -147,7 +159,14 @@ private fun TodayCard( today.shift?.let { shift -> Spacer(Modifier.height(8.dp)) Text( - text = "Shift: ${shift.name} (${shift.startTime}–${shift.endTime})", + text = localizedDigits( + stringResource( + R.string.dash_shift, + shift.name, + shift.startTime.toString(), + shift.endTime.toString(), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -156,13 +175,15 @@ private fun TodayCard( Spacer(Modifier.height(12.dp)) Row { WtPrimaryButton( - text = if (today.clockedIn) "Clock out" else "Clock in", + text = stringResource( + if (today.clockedIn) R.string.dash_clock_out else R.string.dash_clock_in, + ), onClick = onPunchClick, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(12.dp)) WtSecondaryButton( - text = "History", + text = stringResource(R.string.dash_history), onClick = onAttendanceHistoryClick, ) } @@ -180,12 +201,12 @@ private fun BalancesRow(balances: List) { Card { Column(Modifier.padding(12.dp)) { Text( - text = "%.1f".format(balance.availableDays), + text = localizedDigits("%.1f".format(balance.availableDays)), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, ) Text( - text = "days available", + text = stringResource(R.string.dash_days_available), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -211,7 +232,13 @@ private fun AnnouncementCard(announcement: Announcement) { ) if (announcement.priority != AnnouncementPriority.NORMAL) { StatusChip( - text = announcement.priority.name, + text = stringResource( + if (announcement.priority == AnnouncementPriority.URGENT) { + R.string.dash_priority_urgent + } else { + R.string.dash_priority_important + }, + ), tone = if (announcement.priority == AnnouncementPriority.URGENT) { ChipTone.NEGATIVE } else { diff --git a/feature/dashboard/src/main/res/values-en/strings.xml b/feature/dashboard/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..6051260 --- /dev/null +++ b/feature/dashboard/src/main/res/values-en/strings.xml @@ -0,0 +1,19 @@ + + + Hello, %1$s + Clocked in + Not clocked in yet + First in %1$s + Worked %1$sh %2$sm + Shift: %1$s (%2$s–%3$s) + Clock in + Clock out + History + Leave balances + days available + Announcements + IN + OUT + Important + Urgent + diff --git a/feature/dashboard/src/main/res/values-ps/strings.xml b/feature/dashboard/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..66b7e56 --- /dev/null +++ b/feature/dashboard/src/main/res/values-ps/strings.xml @@ -0,0 +1,19 @@ + + + سلام، %1$s + د ورتګ حاضري ثبت شوې + تر اوسه حاضري نه ده ثبت شوې + لومړی ورتګ %1$s + کار %1$s ساعته او %2$s دقیقې + شفټ: %1$s (%2$s تر %3$s) + ورتګ ثبت کړئ + وتل ثبت کړئ + تاریخچه + د رخصتۍ بیلانس + پاتې ورځې + اعلانونه + حاضر + بهر + مهم + بېړنی + diff --git a/feature/dashboard/src/main/res/values/strings.xml b/feature/dashboard/src/main/res/values/strings.xml new file mode 100644 index 0000000..2ba9f18 --- /dev/null +++ b/feature/dashboard/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + سلام، %1$s + حاضری ورود ثبت شده + هنوز حاضری نزده‌اید + اولین ورود %1$s + کارکرد %1$s ساعت و %2$s دقیقه + شفت: %1$s (%2$s تا %3$s) + ثبت ورود + ثبت خروج + تاریخچه + بیلانس رخصتی + روز باقی‌مانده + اعلانات + حاضر + خارج + مهم + عاجل + diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt index 915f0a3..fe9829a 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt @@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -36,10 +38,13 @@ import app.worktrack.core.designsystem.component.SectionHeader import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtTextField import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiDate +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.feature.leave.R import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset -import java.time.format.DateTimeFormatter @Composable fun ApplyLeaveRoute( @@ -49,18 +54,20 @@ fun ApplyLeaveRoute( val state by viewModel.uiState.collectAsStateWithLifecycle() val types by viewModel.types.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { ApplyLeaveEffect.Submitted -> onBack() - is ApplyLeaveEffect.Message -> snackbarHostState.showSnackbar(effect.text) + is ApplyLeaveEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) } } } Scaffold( - topBar = { WtTopBar(title = "Apply for leave", onBack = onBack) }, + topBar = { WtTopBar(title = stringResource(R.string.leave_apply_title), onBack = onBack) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> ApplyLeaveScreen( @@ -100,7 +107,7 @@ internal fun ApplyLeaveScreen( .verticalScroll(rememberScrollState()) .padding(bottom = 32.dp), ) { - SectionHeader("Leave type") + SectionHeader(stringResource(R.string.leave_type_section)) Row( Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -113,25 +120,36 @@ internal fun ApplyLeaveScreen( ) } } - state.fieldErrors["leaveTypeId"]?.let { FieldError(it) } + if ("leaveTypeId" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_type)) - SectionHeader("Dates") + SectionHeader(stringResource(R.string.leave_dates_section)) Row( Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val dateFormat = DateTimeFormatter.ofPattern("d MMM yyyy") AssistChip( onClick = { datePickerTarget = DateTarget.START }, - label = { Text(state.startDate?.format(dateFormat) ?: "Start date") }, + label = { + Text( + state.startDate + ?.let { formatShamsiDate(it, withYear = true) } + ?: stringResource(R.string.leave_start_date), + ) + }, ) AssistChip( onClick = { datePickerTarget = DateTarget.END }, - label = { Text(state.endDate?.format(dateFormat) ?: "End date") }, + label = { + Text( + state.endDate + ?.let { formatShamsiDate(it, withYear = true) } + ?: stringResource(R.string.leave_end_date), + ) + }, ) } - state.fieldErrors["startDate"]?.let { FieldError(it) } - state.fieldErrors["endDate"]?.let { FieldError(it) } + if ("startDate" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_start)) + if ("endDate" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_end)) Row( Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -140,40 +158,45 @@ internal fun ApplyLeaveScreen( FilterChip( selected = state.startHalfDay, onClick = onStartHalfDayToggle, - label = { Text("Half first day") }, + label = { Text(stringResource(R.string.leave_half_first)) }, ) FilterChip( selected = state.endHalfDay, onClick = onEndHalfDayToggle, - label = { Text("Half last day") }, + label = { Text(stringResource(R.string.leave_half_last)) }, ) } if (state.estimatedDays > 0) { Text( - text = "≈ %.1f days".format(state.estimatedDays) + - " (weekends/holidays excluded on approval)", + text = localizedDigits( + stringResource(R.string.leave_estimate, "%.1f".format(state.estimatedDays)), + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp), ) } - SectionHeader("Reason") + SectionHeader(stringResource(R.string.leave_reason_section)) WtTextField( value = state.reason, onValueChange = onReasonChange, - label = "Why do you need this leave?", + label = stringResource(R.string.leave_reason_label), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - errorText = state.fieldErrors["reason"], + errorText = if ("reason" in state.fieldErrors) { + stringResource(R.string.leave_err_reason) + } else { + null + }, singleLine = false, ) Spacer(Modifier.height(24.dp)) WtPrimaryButton( - text = "Submit request", + text = stringResource(R.string.leave_submit), onClick = onSubmit, modifier = Modifier .fillMaxWidth() @@ -206,10 +229,12 @@ internal fun ApplyLeaveScreen( } datePickerTarget = null }, - ) { Text("OK") } + ) { Text(stringResource(app.worktrack.core.designsystem.R.string.ds_ok)) } }, dismissButton = { - TextButton(onClick = { datePickerTarget = null }) { Text("Cancel") } + TextButton(onClick = { datePickerTarget = null }) { + Text(stringResource(app.worktrack.core.designsystem.R.string.ds_cancel)) + } }, ) { DatePicker(state = pickerState) diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt index 706ec6d..d9afb2c 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult -import app.worktrack.core.common.result.userMessage import app.worktrack.core.domain.repository.LeaveRepository import app.worktrack.core.domain.usecase.leave.ApplyLeaveUseCase import app.worktrack.core.model.LeaveApplication @@ -30,7 +29,8 @@ data class ApplyLeaveUiState( val endHalfDay: Boolean = false, val reason: String = "", val isSubmitting: Boolean = false, - val fieldErrors: Map = emptyMap(), + /** Field keys with problems; the UI maps keys to localized messages. */ + val fieldErrors: Set = emptySet(), ) { val estimatedDays: Double get() { @@ -45,7 +45,7 @@ data class ApplyLeaveUiState( sealed interface ApplyLeaveEffect { data object Submitted : ApplyLeaveEffect - data class Message(val text: String) : ApplyLeaveEffect + data class Failed(val error: AppError) : ApplyLeaveEffect } @HiltViewModel @@ -93,17 +93,18 @@ class ApplyLeaveViewModel @Inject constructor( val typeId = state.leaveTypeId val start = state.startDate val end = state.endDate - val missing = buildMap { - if (typeId == null) put("leaveTypeId", "Choose a leave type") - if (start == null) put("startDate", "Choose a start date") - if (end == null) put("endDate", "Choose an end date") + val missing = buildSet { + if (typeId == null) add("leaveTypeId") + if (start == null) add("startDate") + if (end == null) add("endDate") + if (state.reason.isBlank()) add("reason") } if (missing.isNotEmpty() || typeId == null || start == null || end == null) { _uiState.update { it.copy(fieldErrors = missing) } return } - _uiState.update { it.copy(isSubmitting = true, fieldErrors = emptyMap()) } + _uiState.update { it.copy(isSubmitting = true, fieldErrors = emptySet()) } viewModelScope.launch { val result = applyLeave( LeaveApplication( @@ -120,10 +121,11 @@ class ApplyLeaveViewModel @Inject constructor( is AppResult.Failure -> { _uiState.update { it.copy( - fieldErrors = (result.error as? AppError.Validation)?.fieldErrors.orEmpty(), + fieldErrors = (result.error as? AppError.Validation) + ?.fieldErrors?.keys.orEmpty(), ) } - _effects.send(ApplyLeaveEffect.Message(result.error.userMessage())) + _effects.send(ApplyLeaveEffect.Failed(result.error)) } } _uiState.update { it.copy(isSubmitting = false) } diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt index 3b12d06..75cf69e 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -36,9 +38,12 @@ import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtSecondaryButton import app.worktrack.core.designsystem.component.WtTextField import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiRange +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage import app.worktrack.core.model.ApprovalDecision import app.worktrack.core.model.LeaveRequest -import java.time.format.DateTimeFormatter +import app.worktrack.feature.leave.R @Composable fun ApprovalsRoute( @@ -49,20 +54,36 @@ fun ApprovalsRoute( val deciding by viewModel.deciding.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var rejectTarget by remember { mutableStateOf(null) } + val context = LocalContext.current LaunchedEffect(Unit) { - viewModel.messages.collect { snackbarHostState.showSnackbar(it) } + viewModel.effects.collect { effect -> + when (effect) { + is ApprovalsEffect.Decided -> snackbarHostState.showSnackbar( + context.getString( + if (effect.decision == ApprovalDecision.APPROVE) { + R.string.leave_msg_approved + } else { + R.string.leave_msg_rejected + }, + ), + ) + + is ApprovalsEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } } Scaffold( - topBar = { WtTopBar(title = "Approvals", onBack = onBack) }, + topBar = { WtTopBar(title = stringResource(R.string.leave_approvals_title), onBack = onBack) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> if (pending.isEmpty()) { EmptyState( icon = Icons.Filled.Inbox, - title = "All caught up", - message = "No leave requests are waiting for your decision.", + title = stringResource(R.string.leave_approvals_empty_title), + message = stringResource(R.string.leave_approvals_empty_msg), modifier = Modifier.padding(padding), ) } else { @@ -88,7 +109,7 @@ fun ApprovalsRoute( rejectTarget?.let { target -> RejectDialog( - employeeName = target.employeeName ?: "this employee", + employeeName = target.employeeName ?: target.employeeId, onConfirm = { note -> viewModel.onDecide(target.id, ApprovalDecision.REJECT, note) rejectTarget = null @@ -105,7 +126,6 @@ private fun ApprovalCard( onApprove: () -> Unit, onReject: () -> Unit, ) { - val dateFormat = DateTimeFormatter.ofPattern("d MMM") Card( Modifier .fillMaxWidth() @@ -117,8 +137,11 @@ private fun ApprovalCard( style = MaterialTheme.typography.titleSmall, ) Text( - text = "${request.startDate.format(dateFormat)} – " + - "${request.endDate.format(dateFormat)} · %.1f days".format(request.days), + text = formatShamsiRange(request.startDate, request.endDate) + + " · " + + localizedDigits( + stringResource(R.string.leave_days_count, "%.1f".format(request.days)), + ), style = MaterialTheme.typography.bodyMedium, ) Text( @@ -129,14 +152,14 @@ private fun ApprovalCard( Spacer(Modifier.height(8.dp)) Row { WtPrimaryButton( - text = "Approve", + text = stringResource(R.string.leave_approve), onClick = onApprove, modifier = Modifier.weight(1f), loading = busy, ) Spacer(Modifier.width(8.dp)) WtSecondaryButton( - text = "Reject", + text = stringResource(R.string.leave_reject), onClick = onReject, modifier = Modifier.weight(1f), enabled = !busy, @@ -155,15 +178,15 @@ private fun RejectDialog( var note by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Reject request") }, + title = { Text(stringResource(R.string.leave_reject_dialog_title)) }, text = { Column { - Text("Tell $employeeName why this request is being rejected.") + Text(stringResource(R.string.leave_reject_dialog_msg, employeeName)) Spacer(Modifier.height(8.dp)) WtTextField( value = note, onValueChange = { note = it }, - label = "Reason", + label = stringResource(R.string.leave_reject_reason), singleLine = false, ) } @@ -172,10 +195,12 @@ private fun RejectDialog( TextButton( onClick = { onConfirm(note) }, enabled = note.isNotBlank(), - ) { Text("Reject") } + ) { Text(stringResource(R.string.leave_reject)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { + Text(stringResource(app.worktrack.core.designsystem.R.string.ds_cancel)) + } }, ) } diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt index a461852..638f8f9 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt @@ -2,8 +2,8 @@ package app.worktrack.feature.leave.approvals import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult -import app.worktrack.core.common.result.userMessage import app.worktrack.core.domain.usecase.leave.DecideLeaveRequestUseCase import app.worktrack.core.domain.usecase.leave.ObservePendingApprovalsUseCase import app.worktrack.core.model.ApprovalDecision @@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +sealed interface ApprovalsEffect { + data class Decided(val decision: ApprovalDecision) : ApprovalsEffect + data class Failed(val error: AppError) : ApprovalsEffect +} + @HiltViewModel class ApprovalsViewModel @Inject constructor( observePendingApprovals: ObservePendingApprovalsUseCase, @@ -33,19 +38,16 @@ class ApprovalsViewModel @Inject constructor( private val _deciding = MutableStateFlow>(emptySet()) val deciding: StateFlow> = _deciding.asStateFlow() - private val _messages = Channel(Channel.BUFFERED) - val messages = _messages.receiveAsFlow() + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() fun onDecide(requestId: String, decision: ApprovalDecision, note: String?) { if (requestId in _deciding.value) return _deciding.update { it + requestId } viewModelScope.launch { when (val result = decideRequest(requestId, decision, note)) { - is AppResult.Success -> _messages.send( - if (decision == ApprovalDecision.APPROVE) "Request approved" else "Request rejected", - ) - - is AppResult.Failure -> _messages.send(result.error.userMessage()) + is AppResult.Success -> _effects.send(ApprovalsEffect.Decided(decision)) + is AppResult.Failure -> _effects.send(ApprovalsEffect.Failed(result.error)) } _deciding.update { it - requestId } } diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt index 197be55..57ba0c6 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt @@ -2,6 +2,7 @@ package app.worktrack.feature.leave.overview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -30,6 +31,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -38,12 +41,15 @@ import app.worktrack.core.designsystem.component.ColorDotChip import app.worktrack.core.designsystem.component.EmptyState import app.worktrack.core.designsystem.component.SectionHeader import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.l10n.formatShamsiRange +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage import app.worktrack.core.model.LeaveBalance import app.worktrack.core.model.LeaveRequest import app.worktrack.core.model.LeaveStatus import app.worktrack.core.model.LeaveType import app.worktrack.core.model.SyncStatus -import java.time.format.DateTimeFormatter +import app.worktrack.feature.leave.R @Composable fun LeaveOverviewRoute( @@ -53,9 +59,18 @@ fun LeaveOverviewRoute( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(Unit) { - viewModel.messages.collect { snackbarHostState.showSnackbar(it) } + viewModel.effects.collect { effect -> + when (effect) { + LeaveOverviewEffect.Cancelled -> + snackbarHostState.showSnackbar(context.getString(R.string.leave_msg_cancelled)) + + is LeaveOverviewEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } } Scaffold( @@ -64,7 +79,7 @@ fun LeaveOverviewRoute( ExtendedFloatingActionButton( onClick = onApplyClick, icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text("Apply") }, + text = { Text(stringResource(R.string.leave_apply)) }, ) }, ) { padding -> @@ -100,28 +115,30 @@ internal fun LeaveOverviewScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "Team requests waiting for you", + text = stringResource(R.string.leave_pending_team), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - TextButton(onClick = onApprovalsClick) { Text("Review") } + TextButton(onClick = onApprovalsClick) { + Text(stringResource(R.string.leave_review)) + } } } } } - item { SectionHeader("Balances") } + item { SectionHeader(stringResource(R.string.leave_balances)) } item { BalanceRow(balances = state.overview.balances, typeOf = { state.overview.typeOf(it) }) } - item { SectionHeader("My requests") } + item { SectionHeader(stringResource(R.string.leave_my_requests)) } if (state.overview.myRequests.isEmpty()) { item { EmptyState( icon = Icons.Filled.BeachAccess, - title = "No leave requests yet", - message = "Tap Apply to request time off.", + title = stringResource(R.string.leave_empty_title), + message = stringResource(R.string.leave_empty_msg), modifier = Modifier.height(280.dp), ) } @@ -145,7 +162,7 @@ private fun BalanceRow( ) { if (balances.isEmpty()) { Text( - text = "Balances appear after your first sync.", + text = stringResource(R.string.leave_balances_after_sync), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp), @@ -153,7 +170,7 @@ private fun BalanceRow( return } LazyRow( - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(balances, key = { it.id }) { balance -> @@ -161,17 +178,22 @@ private fun BalanceRow( Card { Column(Modifier.padding(12.dp)) { Text( - text = "%.1f".format(balance.availableDays), + text = localizedDigits("%.1f".format(balance.availableDays)), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, ) Text( - text = type?.name ?: "Leave", + text = type?.name ?: stringResource(R.string.leave_generic_type), style = MaterialTheme.typography.labelMedium, ) if (balance.pendingDays > 0) { Text( - text = "%.1f pending".format(balance.pendingDays), + text = localizedDigits( + stringResource( + R.string.leave_days_pending, + "%.1f".format(balance.pendingDays), + ), + ), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -188,7 +210,6 @@ private fun RequestCard( type: LeaveType?, onCancel: () -> Unit, ) { - val dateFormat = DateTimeFormatter.ofPattern("d MMM") Card( Modifier .fillMaxWidth() @@ -205,8 +226,14 @@ private fun RequestCard( } Spacer(Modifier.height(4.dp)) Text( - text = "${request.startDate.format(dateFormat)} – " + - "${request.endDate.format(dateFormat)} · %.1f days".format(request.days), + text = formatShamsiRange(request.startDate, request.endDate) + + " · " + + localizedDigits( + stringResource( + R.string.leave_days_count, + "%.1f".format(request.days), + ), + ), style = MaterialTheme.typography.titleSmall, ) Text( @@ -220,32 +247,37 @@ private fun RequestCard( } if (request.syncStatus == SyncStatus.PENDING) { Text( - text = "Waiting to sync…", + text = stringResource(R.string.leave_waiting_sync), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (request.syncStatus == SyncStatus.FAILED) { Text( - text = "Sync failed — the server rejected this request", + text = stringResource(R.string.leave_sync_failed), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, ) } if (request.status == LeaveStatus.PENDING && request.syncStatus == SyncStatus.SYNCED) { - TextButton(onClick = onCancel) { Text("Cancel request") } + TextButton(onClick = onCancel) { + Text(stringResource(R.string.leave_cancel_request)) + } } } } } -internal fun LeaveStatus.label(): String = when (this) { - LeaveStatus.DRAFT -> "Draft" - LeaveStatus.PENDING -> "Pending" - LeaveStatus.APPROVED -> "Approved" - LeaveStatus.REJECTED -> "Rejected" - LeaveStatus.CANCELLED -> "Cancelled" -} +@Composable +internal fun LeaveStatus.label(): String = stringResource( + when (this) { + LeaveStatus.DRAFT -> R.string.leave_status_draft + LeaveStatus.PENDING -> R.string.leave_status_pending + LeaveStatus.APPROVED -> R.string.leave_status_approved + LeaveStatus.REJECTED -> R.string.leave_status_rejected + LeaveStatus.CANCELLED -> R.string.leave_status_cancelled + }, +) internal fun LeaveStatus.tone(): ChipTone = when (this) { LeaveStatus.APPROVED -> ChipTone.POSITIVE diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt index 6919442..958f2ac 100644 --- a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt @@ -2,8 +2,8 @@ package app.worktrack.feature.leave.overview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult -import app.worktrack.core.common.result.userMessage import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase import app.worktrack.core.domain.usecase.leave.CancelLeaveRequestUseCase import app.worktrack.core.domain.usecase.leave.LeaveOverview @@ -23,6 +23,11 @@ data class LeaveOverviewUiState( val isApprover: Boolean = false, ) +sealed interface LeaveOverviewEffect { + data object Cancelled : LeaveOverviewEffect + data class Failed(val error: AppError) : LeaveOverviewEffect +} + @HiltViewModel class LeaveOverviewViewModel @Inject constructor( observeOverview: ObserveLeaveOverviewUseCase, @@ -30,8 +35,8 @@ class LeaveOverviewViewModel @Inject constructor( private val cancelRequest: CancelLeaveRequestUseCase, ) : ViewModel() { - private val _messages = Channel(Channel.BUFFERED) - val messages = _messages.receiveAsFlow() + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() val uiState: StateFlow = combine( observeOverview(), @@ -50,8 +55,8 @@ class LeaveOverviewViewModel @Inject constructor( fun onCancelRequest(requestId: String) { viewModelScope.launch { when (val result = cancelRequest(requestId)) { - is AppResult.Success -> _messages.send("Request cancelled") - is AppResult.Failure -> _messages.send(result.error.userMessage()) + is AppResult.Success -> _effects.send(LeaveOverviewEffect.Cancelled) + is AppResult.Failure -> _effects.send(LeaveOverviewEffect.Failed(result.error)) } } } diff --git a/feature/leave/src/main/res/values-en/strings.xml b/feature/leave/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..165a1da --- /dev/null +++ b/feature/leave/src/main/res/values-en/strings.xml @@ -0,0 +1,51 @@ + + + Apply + Team requests are waiting for you + Review + Balances + My requests + No leave requests yet + Tap Apply to request time off. + Balances appear after your first sync. + %1$s days pending + %1$s days + Waiting to sync… + Sync failed — the server rejected this request + Cancel request + Leave + Request cancelled + + Draft + Pending + Approved + Rejected + Cancelled + + Apply for leave + Leave type + Dates + Start date + End date + Half first day + Half last day + ≈ %1$s days (Fridays and public holidays are settled on approval) + Reason + Why do you need this leave? + Submit request + Choose a leave type + Choose a start date + Choose an end date + A reason is required + + Approvals + All caught up + No leave requests are waiting for your decision. + Approve + Reject + Reject request + Tell %1$s why this request is being rejected. + Reason + Request approved + Request rejected + diff --git a/feature/leave/src/main/res/values-ps/strings.xml b/feature/leave/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..b60ae1f --- /dev/null +++ b/feature/leave/src/main/res/values-ps/strings.xml @@ -0,0 +1,51 @@ + + + غوښتنه + د ټیم غوښتنې ستاسو په تمه دي + کتنه + بیلانسونه + زما غوښتنې + تر اوسه غوښتنه نه لرئ + د رخصتۍ غوښتنې لپاره د غوښتنې تڼۍ کېکاږئ. + بیلانسونه د لومړي همغږي کولو وروسته ښکاري. + %1$s ورځې په تمه + %1$s ورځې + د همغږۍ په تمه… + همغږي ناکامه شوه — سرور دا غوښتنه رد کړه + غوښتنه لغوه کړئ + رخصتي + غوښتنه لغوه شوه + + مسوده + په تمه + تایید شوې + رد شوې + لغوه شوې + + د رخصتۍ غوښتنه + د رخصتۍ ډول + نېټې + د پیل نېټه + د پای نېټه + لومړۍ نیمه ورځ + وروستۍ نیمه ورځ + ≈ %1$s ورځې (جمعې او عمومي رخصتۍ د تایید پر مهال حسابېږي) + دلیل + ولې دې رخصتۍ ته اړتیا لرئ؟ + غوښتنه واستوئ + د رخصتۍ ډول وټاکئ + د پیل نېټه وټاکئ + د پای نېټه وټاکئ + دلیل لیکل اړین دي + + تاییدونه + ټول کتل شوي + هېڅ د رخصتۍ غوښتنه ستاسو د پرېکړې په تمه نه ده. + تایید + رد + غوښتنه رد کړئ + %1$s ته ووایاست چې دا غوښتنه ولې ردېږي. + دلیل + غوښتنه تایید شوه + غوښتنه رد شوه + diff --git a/feature/leave/src/main/res/values/strings.xml b/feature/leave/src/main/res/values/strings.xml new file mode 100644 index 0000000..1118336 --- /dev/null +++ b/feature/leave/src/main/res/values/strings.xml @@ -0,0 +1,51 @@ + + + درخواست + درخواست‌های تیم منتظر شماست + بررسی + بیلانس‌ها + درخواست‌های من + هنوز درخواستی ندارید + برای درخواست رخصتی، دکمهٔ درخواست را بزنید. + بیلانس‌ها بعد از اولین همگام‌سازی نمایش داده می‌شود. + %1$s روز در انتظار + %1$s روز + در انتظار همگام‌سازی… + همگام‌سازی ناکام شد — سرور این درخواست را رد کرد + لغو درخواست + رخصتی + درخواست لغو شد + + مسوده + در انتظار + تایید شده + رد شده + لغو شده + + درخواست رخصتی + نوع رخصتی + تاریخ‌ها + تاریخ شروع + تاریخ ختم + نیم روز اول + نیم روز آخر + ≈ %1$s روز (جمعه‌ها و رخصتی‌های عمومی هنگام تایید حساب می‌شود) + دلیل + چرا به این رخصتی نیاز دارید؟ + ارسال درخواست + نوع رخصتی را انتخاب کنید + تاریخ شروع را انتخاب کنید + تاریخ ختم را انتخاب کنید + نوشتن دلیل لازم است + + تاییدی‌ها + همه بررسی شده + هیچ درخواست رخصتی منتظر فیصلهٔ شما نیست. + تایید + رد + رد درخواست + به %1$s بگویید چرا این درخواست رد می‌شود. + دلیل + درخواست تایید شد + درخواست رد شد + diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt index 6e76b53..7d499a3 100644 --- a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt @@ -22,15 +22,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits import app.worktrack.core.model.Payslip -import java.time.Month -import java.time.format.TextStyle -import java.util.Locale @Composable fun PayslipsRoute( @@ -47,24 +47,33 @@ fun PayslipsRoute( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = viewModel::onPreviousYear) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous year") + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.pay_prev_year), + ) } Text( - text = state.year.toString(), + text = localizedDigits(state.year.toString()), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, ) IconButton(onClick = viewModel::onNextYear, enabled = state.canGoForward) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next year") + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.pay_next_year), + ) } } if (state.payslips.isEmpty()) { EmptyState( icon = Icons.Filled.ReceiptLong, - title = "No payslips for ${state.year}", - message = "Payslips appear here once payroll is finalized.", + title = stringResource( + R.string.pay_no_payslips_title, + localizedDigits(state.year.toString()), + ), + message = stringResource(R.string.pay_no_payslips_msg), ) } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -90,20 +99,28 @@ private fun PayslipCard(payslip: Payslip, onClick: () -> Unit) { ) { Column(Modifier.weight(1f)) { Text( - text = "${ - Month.of(payslip.periodMonth).getDisplayName(TextStyle.FULL, Locale.getDefault()) - } ${payslip.periodYear}", + // Payroll periods are Solar Hijri months (e.g. "سرطان ۱۴۰۵"). + text = formatShamsiMonthYear(payslip.periodYear, payslip.periodMonth), style = MaterialTheme.typography.titleSmall, ) + val worked = localizedDigits( + stringResource(R.string.pay_worked_days, "%.1f".format(payslip.workedDays)), + ) + val lop = if (payslip.lopDays > 0) { + " · " + localizedDigits( + stringResource(R.string.pay_lop_days, "%.1f".format(payslip.lopDays)), + ) + } else { + "" + } Text( - text = "Worked %.1f days".format(payslip.workedDays) + - if (payslip.lopDays > 0) " · LOP %.1f".format(payslip.lopDays) else "", + text = worked + lop, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( - text = "${payslip.currency} ${"%,.2f".format(payslip.net)}", + text = localizedDigits("${payslip.currency} ${"%,.2f".format(payslip.net)}"), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, ) diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt index 05aafed..32c5203 100644 --- a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt @@ -3,6 +3,7 @@ package app.worktrack.feature.payslips import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.time.SolarHijri import app.worktrack.core.common.time.TimeProvider import app.worktrack.core.domain.repository.PayslipRepository import app.worktrack.core.domain.usecase.payslip.ObservePayslipsUseCase @@ -16,6 +17,10 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** + * Payroll periods are **Solar Hijri** months/years for Afghan tenants: + * periodYear/periodMonth on payslips carry Shamsi values (e.g. 1405/4 = Saratan). + */ data class PayslipsUiState( val year: Int, val payslips: List = emptyList(), @@ -30,8 +35,10 @@ class PayslipsViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, ) : ViewModel() { + private fun currentShamsiYear(): Int = SolarHijri.today(timeProvider).year + private val year: StateFlow = - savedStateHandle.getStateFlow(KEY_YEAR, timeProvider.today().year) + savedStateHandle.getStateFlow(KEY_YEAR, currentShamsiYear()) val uiState: StateFlow = year .flatMapLatest { selected -> observePayslips(selected) } @@ -39,7 +46,7 @@ class PayslipsViewModel @Inject constructor( PayslipsUiState( year = selected, payslips = slips, - canGoForward = selected < timeProvider.today().year, + canGoForward = selected < currentShamsiYear(), ) } .stateIn( @@ -59,7 +66,7 @@ class PayslipsViewModel @Inject constructor( private fun shiftYear(delta: Int) { val target = year.value + delta - if (target > timeProvider.today().year) return + if (target > currentShamsiYear()) return savedStateHandle[KEY_YEAR] = target viewModelScope.launch { payslipRepository.refresh(target) } } diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt index 2531ff4..3af0129 100644 --- a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt @@ -18,17 +18,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.worktrack.core.designsystem.component.FullScreenLoading import app.worktrack.core.designsystem.component.SectionHeader import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits import app.worktrack.core.model.PayComponentType import app.worktrack.core.model.Payslip -import java.time.Month -import java.time.format.TextStyle -import java.util.Locale +import app.worktrack.feature.payslips.R @Composable fun PayslipDetailRoute( @@ -40,9 +41,8 @@ fun PayslipDetailRoute( Scaffold( topBar = { WtTopBar( - title = payslip?.let { - "${Month.of(it.periodMonth).getDisplayName(TextStyle.SHORT, Locale.getDefault())} ${it.periodYear}" - } ?: "Payslip", + title = payslip?.let { formatShamsiMonthYear(it.periodYear, it.periodMonth) } + ?: stringResource(R.string.pay_payslip), onBack = onBack, ) }, @@ -71,15 +71,23 @@ private fun PayslipDetail(payslip: Payslip, modifier: Modifier = Modifier) { ), ) { Column(Modifier.padding(16.dp)) { - Text("Net pay", style = MaterialTheme.typography.labelMedium) Text( - text = "${payslip.currency} ${"%,.2f".format(payslip.net)}", + text = stringResource(R.string.pay_net_pay), + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = localizedDigits("${payslip.currency} ${"%,.2f".format(payslip.net)}"), style = MaterialTheme.typography.headlineMedium, ) Spacer(Modifier.height(4.dp)) Text( - text = "Gross ${"%,.2f".format(payslip.gross)} − " + - "Deductions ${"%,.2f".format(payslip.totalDeductions)}", + text = localizedDigits( + stringResource( + R.string.pay_gross_minus, + "%,.2f".format(payslip.gross), + "%,.2f".format(payslip.totalDeductions), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -90,20 +98,20 @@ private fun PayslipDetail(payslip: Payslip, modifier: Modifier = Modifier) { val deductions = payslip.lines.filter { it.type == PayComponentType.DEDUCTION } if (earnings.isNotEmpty()) { - SectionHeader("Earnings") + SectionHeader(stringResource(R.string.pay_earnings)) LinesCard(lines = earnings.map { it.componentName to it.amount }, currency = payslip.currency) } if (deductions.isNotEmpty()) { - SectionHeader("Deductions") + SectionHeader(stringResource(R.string.pay_deductions)) LinesCard(lines = deductions.map { it.componentName to it.amount }, currency = payslip.currency) } - SectionHeader("Attendance summary") + SectionHeader(stringResource(R.string.pay_attendance_summary)) LinesCard( lines = listOf( - "Worked days" to payslip.workedDays, - "Paid leave days" to payslip.paidLeaveDays, - "Loss of pay days" to payslip.lopDays, + stringResource(R.string.pay_worked_days_label) to payslip.workedDays, + stringResource(R.string.pay_paid_leave_label) to payslip.paidLeaveDays, + stringResource(R.string.pay_lop_label) to payslip.lopDays, ), currency = null, ) @@ -127,11 +135,13 @@ private fun LinesCard(lines: List>, currency: String?) { modifier = Modifier.weight(1f), ) Text( - text = if (currency != null) { - "$currency ${"%,.2f".format(amount)}" - } else { - "%.1f".format(amount) - }, + text = localizedDigits( + if (currency != null) { + "$currency ${"%,.2f".format(amount)}" + } else { + "%.1f".format(amount) + }, + ), style = MaterialTheme.typography.bodyMedium, ) } diff --git a/feature/payslips/src/main/res/values-en/strings.xml b/feature/payslips/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..c119abf --- /dev/null +++ b/feature/payslips/src/main/res/values-en/strings.xml @@ -0,0 +1,18 @@ + + + Payslip + No payslips for %1$s + Payslips appear here once payroll is finalized. + Previous year + Next year + Worked %1$s days + LOP %1$s days + Net pay + Gross %1$s − Deductions %2$s + Earnings + Deductions + Attendance summary + Worked days + Paid leave days + Loss-of-pay days + diff --git a/feature/payslips/src/main/res/values-ps/strings.xml b/feature/payslips/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..1e8ccfe --- /dev/null +++ b/feature/payslips/src/main/res/values-ps/strings.xml @@ -0,0 +1,18 @@ + + + د معاش فیش + د %1$s کال لپاره د معاش فیش نشته + د معاش فیشونه د معاشاتو له نهایي کېدو وروسته ښکاري. + پخوانی کال + راتلونکی کال + %1$s ورځې کار + %1$s ورځې د معاش کسر + خالص معاش + ناخالص %1$s − کسرات %2$s + عواید + کسرات + د حاضرۍ لنډیز + د کار ورځې + له معاش سره د رخصتۍ ورځې + د معاش کسر ورځې + diff --git a/feature/payslips/src/main/res/values/strings.xml b/feature/payslips/src/main/res/values/strings.xml new file mode 100644 index 0000000..29ef90c --- /dev/null +++ b/feature/payslips/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + فیش معاش + فیش معاشی برای سال %1$s نیست + فیش‌های معاش بعد از نهایی شدن معاشات نمایش داده می‌شود. + سال قبلی + سال بعدی + %1$s روز کارکرد + %1$s روز کسر معاش + معاش خالص + ناخالص %1$s − کسرات %2$s + عواید + کسرات + خلاصهٔ حاضری + روزهای کارکرد + روزهای رخصتی با معاش + روزهای کسر معاش + diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 44b4060..6a34e35 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -5,3 +5,8 @@ plugins { android { namespace = "app.worktrack.feature.profile" } + +dependencies { + // AppCompatDelegate drives the in-app language switch (Dari/Pashto/English). + implementation(libs.androidx.appcompat) +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt index 90e9148..6441cdd 100644 --- a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt @@ -1,5 +1,6 @@ package app.worktrack.feature.profile +import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,13 +12,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.worktrack.core.designsystem.component.ChipTone @@ -26,10 +30,19 @@ import app.worktrack.core.designsystem.component.SectionHeader import app.worktrack.core.designsystem.component.StatusChip import app.worktrack.core.designsystem.component.WtPrimaryButton import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.l10n.appLocale +import app.worktrack.core.designsystem.l10n.formatShamsiDateTime +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.model.RoleCode import app.worktrack.core.model.SyncState import app.worktrack.core.model.UserSession -import java.time.ZoneId -import java.time.format.DateTimeFormatter + +/** App language options; tags feed AppCompatDelegate.setApplicationLocales. */ +private enum class AppLanguage(val tag: String, val labelRes: Int) { + DARI("fa-AF", R.string.prof_lang_dari), + PASHTO("ps-AF", R.string.prof_lang_pashto), + ENGLISH("en", R.string.prof_lang_english), +} @Composable fun ProfileRoute( @@ -47,6 +60,11 @@ fun ProfileRoute( syncState = state.syncState, isSigningOut = state.isSigningOut, onPayslipsClick = onPayslipsClick, + onLanguageSelect = { language -> + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(language), + ) + }, onSyncNow = viewModel::onSyncNow, onSignOut = viewModel::onSignOut, ) @@ -58,6 +76,7 @@ internal fun ProfileScreen( syncState: SyncState?, isSigningOut: Boolean, onPayslipsClick: () -> Unit, + onLanguageSelect: (String) -> Unit, onSyncNow: () -> Unit, onSignOut: () -> Unit, ) { @@ -87,19 +106,18 @@ internal fun ProfileScreen( Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { session.roles.forEach { role -> - StatusChip( - text = role.name.replace('_', ' ').lowercase() - .replaceFirstChar { it.uppercase() }, - tone = ChipTone.NEUTRAL, - ) + StatusChip(text = role.label(), tone = ChipTone.NEUTRAL) } } } } - SectionHeader("Payroll") + SectionHeader(stringResource(R.string.prof_language)) + LanguageRow(onLanguageSelect = onLanguageSelect) + + SectionHeader(stringResource(R.string.prof_payroll)) WtSecondaryButton( - text = "My payslips", + text = stringResource(R.string.prof_my_payslips), onClick = onPayslipsClick, modifier = Modifier .fillMaxWidth() @@ -107,7 +125,7 @@ internal fun ProfileScreen( ) Spacer(Modifier.height(8.dp)) - SectionHeader("Sync") + SectionHeader(stringResource(R.string.prof_sync)) Card( Modifier .fillMaxWidth() @@ -116,13 +134,16 @@ internal fun ProfileScreen( Column(Modifier.padding(16.dp)) { SyncStatusRow(syncState) Spacer(Modifier.height(12.dp)) - WtSecondaryButton(text = "Sync now", onClick = onSyncNow) + WtSecondaryButton( + text = stringResource(R.string.prof_sync_now), + onClick = onSyncNow, + ) } } Spacer(Modifier.height(24.dp)) WtPrimaryButton( - text = "Sign out", + text = stringResource(R.string.prof_sign_out), onClick = onSignOut, modifier = Modifier .fillMaxWidth() @@ -132,47 +153,77 @@ internal fun ProfileScreen( } } +@Composable +private fun LanguageRow(onLanguageSelect: (String) -> Unit) { + val currentLanguage = appLocale().language + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AppLanguage.entries.forEach { language -> + FilterChip( + selected = language.tag.startsWith(currentLanguage), + onClick = { onLanguageSelect(language.tag) }, + label = { Text(stringResource(language.labelRes)) }, + ) + } + } +} + @Composable private fun SyncStatusRow(syncState: SyncState?) { if (syncState == null) { - Text("Sync status unavailable", style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.prof_sync_unavailable), + style = MaterialTheme.typography.bodyMedium, + ) return } Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text( text = when { - syncState.isSyncing -> "Syncing…" - syncState.pendingOperations > 0 -> - "${syncState.pendingOperations} changes waiting to sync" + syncState.isSyncing -> stringResource(R.string.prof_syncing) + syncState.pendingOperations > 0 -> localizedDigits( + stringResource( + R.string.prof_pending_changes, + syncState.pendingOperations.toString(), + ), + ) - else -> "Everything is up to date" + else -> stringResource(R.string.prof_up_to_date) }, style = MaterialTheme.typography.bodyMedium, ) syncState.lastSuccessAt?.let { Text( - text = "Last synced " + DateTimeFormatter.ofPattern("d MMM HH:mm") - .format(it.atZone(ZoneId.systemDefault())), + text = stringResource(R.string.prof_last_synced, formatShamsiDateTime(it)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (syncState.failedOperations > 0) { Text( - text = "${syncState.failedOperations} changes were rejected by the server", + text = localizedDigits( + stringResource( + R.string.prof_rejected_changes, + syncState.failedOperations.toString(), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } StatusChip( - text = when { - syncState.isSyncing -> "SYNCING" - syncState.failedOperations > 0 -> "ATTENTION" - syncState.pendingOperations > 0 -> "PENDING" - else -> "OK" - }, + text = stringResource( + when { + syncState.isSyncing -> R.string.prof_chip_syncing + syncState.failedOperations > 0 -> R.string.prof_chip_attention + syncState.pendingOperations > 0 -> R.string.prof_chip_pending + else -> R.string.prof_chip_ok + }, + ), tone = when { syncState.failedOperations > 0 -> ChipTone.NEGATIVE syncState.pendingOperations > 0 || syncState.isSyncing -> ChipTone.WARNING @@ -181,3 +232,18 @@ private fun SyncStatusRow(syncState: SyncState?) { ) } } + +@Composable +private fun RoleCode.label(): String = stringResource( + when (this) { + RoleCode.SUPER_ADMIN -> R.string.prof_role_super_admin + RoleCode.COMPANY_ADMIN -> R.string.prof_role_company_admin + RoleCode.HR_ADMIN -> R.string.prof_role_hr_admin + RoleCode.PAYROLL_ADMIN -> R.string.prof_role_payroll_admin + RoleCode.BRANCH_MANAGER -> R.string.prof_role_branch_manager + RoleCode.TEAM_LEAD -> R.string.prof_role_team_lead + RoleCode.EMPLOYEE -> R.string.prof_role_employee + RoleCode.AUDITOR -> R.string.prof_role_auditor + RoleCode.KIOSK -> R.string.prof_role_kiosk + }, +) diff --git a/feature/profile/src/main/res/values-en/strings.xml b/feature/profile/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..9a5510d --- /dev/null +++ b/feature/profile/src/main/res/values-en/strings.xml @@ -0,0 +1,34 @@ + + + Payroll + My payslips + Language + Sync + Sync now + Syncing… + %1$s changes waiting to sync + Everything is up to date + Last synced: %1$s + %1$s changes were rejected by the server + Sync status unavailable + Sign out + + Syncing + Attention + Pending + Up to date + + System admin + Company admin + HR admin + Payroll admin + Branch manager + Team lead + Employee + Auditor + Kiosk + + دری + پښتو + English + diff --git a/feature/profile/src/main/res/values-ps/strings.xml b/feature/profile/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..5b0b0c7 --- /dev/null +++ b/feature/profile/src/main/res/values-ps/strings.xml @@ -0,0 +1,34 @@ + + + معاشات + زما د معاش فیشونه + ژبه + همغږي + اوس همغږي کړئ + همغږي کېږي… + %1$s بدلونونه د همغږۍ په تمه + هر څه تازه دي + وروستۍ همغږي: %1$s + %1$s بدلونونه سرور رد کړل + د همغږۍ حالت نشته + له حسابه ووځئ + + همغږي کېږي + پاملرنې ته اړتیا + په تمه + تازه + + د سیستم مدیر + د شرکت مدیر + د بشري منابعو مدیر + د معاشاتو مدیر + د څانګې مدیر + د ډلې مشر + کارکوونکی + پلټونکی + کیوسک + + دری + پښتو + English + diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml new file mode 100644 index 0000000..3428c02 --- /dev/null +++ b/feature/profile/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + معاشات + فیش‌های معاش من + زبان + همگام‌سازی + همگام‌سازی فوری + در حال همگام‌سازی… + %1$s تغییر در انتظار همگام‌سازی + همه چیز به‌روز است + آخرین همگام‌سازی: %1$s + %1$s تغییر توسط سرور رد شد + وضعیت همگام‌سازی در دسترس نیست + خروج از حساب + + در حال همگام‌سازی + نیاز به توجه + در انتظار + به‌روز + + مدیر سیستم + مدیر شرکت + مدیر منابع بشری + مدیر معاشات + مدیر شعبه + سرگروپ + کارمند + بازرس + کیوسک + + + دری + پښتو + English + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 683fa0c..4fbba36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ coroutines = "1.9.0" kotlinxSerialization = "1.7.3" androidxCore = "1.13.1" +androidxAppcompat = "1.7.0" androidxLifecycle = "2.8.6" androidxActivity = "1.9.2" composeBom = "2024.09.03" @@ -44,6 +45,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } From 56ec50f9e3a3081cc839e2e55e13a8ce6078a050 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 00:45:59 +0000 Subject: [PATCH 007/139] fix(build): resolve Gradle toolchain sync failure (no JDK 17 installed) Sync failed with NoToolchainAvailableException: "No locally installed toolchains match and toolchain download repositories have not been configured." The build pinned a strict Java 17 toolchain, but machines without a standalone JDK 17 (only the IDE's bundled JBR) had no way to satisfy or download it. - Drop strict Java toolchains in favor of source/target compatibility + Kotlin jvmTarget 17, so any JDK 17+ (including Android Studio's bundled runtime) builds the JVM modules and build-logic without a separate JDK - Add the Foojay toolchain resolver to root settings as a safety net so Gradle can auto-provision a JDK if a toolchain is ever reintroduced - build-logic build script uses .set() (embedded kotlin-dsl compiler has no property-assignment operator) The Android convention already used compatibility flags, so app/feature/ core Android modules were unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- build-logic/convention/build.gradle.kts | 13 +++++++++++-- .../main/kotlin/JvmLibraryConventionPlugin.kt | 18 +++++++++++++++--- settings.gradle.kts | 6 ++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index cfa987f..0689594 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -4,9 +4,18 @@ plugins { group = "app.worktrack.buildlogic" +// Compatibility flags instead of a strict toolchain: any JDK 17+ (including the +// IDE's bundled JBR) can build this, producing Java 17 bytecode. Uses .set() +// (not the `=` assignment) because this file compiles under the embedded +// kotlin-dsl compiler, where property-assignment operators aren't available. java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } diff --git a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt index 77f25e0..b3ad5b2 100644 --- a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt @@ -1,14 +1,21 @@ import app.worktrack.buildlogic.libs +import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension /** * Pure-JVM Kotlin module: fastest to compile and enforces that domain logic * stays free of Android framework types. + * + * Java 17 via compatibility flags (not a strict toolchain) so any JDK 17+ — + * including Android Studio's bundled runtime — builds without needing a + * separately installed or downloaded toolchain. */ class JvmLibraryConventionPlugin : Plugin { override fun apply(target: Project) { @@ -16,8 +23,13 @@ class JvmLibraryConventionPlugin : Plugin { pluginManager.apply("org.jetbrains.kotlin.jvm") extensions.configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + extensions.configure { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 15638b8..27fe5d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,12 @@ pluginManagement { } } +plugins { + // Auto-provisions a matching JDK when the machine has none installed, + // instead of failing sync with NoToolchainAvailableException. + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From ad1a8b2823fc124367470f9d23a8cc783012db5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:03:33 +0000 Subject: [PATCH 008/139] fix(database): widen Int to Long for LocalTime.ofSecondOfDay converter intToLocalTime passed the Int? column value straight to LocalTime.ofSecondOfDay(Long), which Kotlin won't widen implicitly, so :core:database:compileDebugKotlin failed with "Inapplicable candidate(s): static fun ofSecondOfDay(p0: Long): LocalTime". Convert with it.toLong(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- .../kotlin/app/worktrack/core/database/converter/Converters.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt index 19831ec..1058c29 100644 --- a/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt +++ b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt @@ -30,5 +30,6 @@ class Converters { fun localTimeToInt(value: LocalTime?): Int? = value?.toSecondOfDay() @TypeConverter - fun intToLocalTime(value: Int?): LocalTime? = value?.let(LocalTime::ofSecondOfDay) + // ofSecondOfDay takes a Long; Kotlin won't widen Int automatically. + fun intToLocalTime(value: Int?): LocalTime? = value?.let { LocalTime.ofSecondOfDay(it.toLong()) } } From 69b17773e393ed85eabdf41718a9dbff1d92dbc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:07:28 +0000 Subject: [PATCH 009/139] fix(data): encapsulate RoomDatabase.clearAllTables behind core:database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthRepositoryImpl.signOut() called database.clearAllTables(), but clearAllTables() is declared on RoomDatabase — the supertype of WorkTrackDatabase — and core:database exposes Room only as an implementation dependency, so androidx.room.RoomDatabase was not on core:data's compile classpath. This produced four cascading errors in :core:data:compileDebugKotlin (cannot access RoomDatabase supertype, unresolved clearAllTables, and two withContext type-inference failures). Add a WorkTrackDatabase.clearAllTenantData() extension in :core:database (where RoomDatabase is visible) and call it from the repository. Room stays encapsulated in its own module; core:data never references a Room supertype. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- .../core/data/repository/AuthRepositoryImpl.kt | 3 ++- .../worktrack/core/database/DatabaseExtensions.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt index ff07899..decc0fb 100644 --- a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt @@ -8,6 +8,7 @@ import app.worktrack.core.common.result.onFailure import app.worktrack.core.common.result.onSuccess import app.worktrack.core.data.mapper.toSession import app.worktrack.core.database.WorkTrackDatabase +import app.worktrack.core.database.clearAllTenantData import app.worktrack.core.datastore.SessionStore import app.worktrack.core.domain.repository.AuthRepository import app.worktrack.core.model.UserSession @@ -80,7 +81,7 @@ class AuthRepositoryImpl @Inject constructor( sessionStore.clear() withContext(dispatchers.io) { // Tenant data never survives a sign-out on shared devices. - database.clearAllTables() + database.clearAllTenantData() } } diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt new file mode 100644 index 0000000..007e602 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.database + +/** + * Wipes every tenant table. Called on sign-out so tenant data never survives on + * shared devices. Kept here (not in :core:data) so that Room's RoomDatabase + * supertype — where clearAllTables() is declared — stays encapsulated within + * this module and is not leaked onto downstream classpaths. + * + * clearAllTables() is a blocking, @WorkerThread call; invoke it from a + * background dispatcher (the repository wraps this in withContext(io)). + */ +fun WorkTrackDatabase.clearAllTenantData() { + clearAllTables() +} From ca1019b3b5f32669670b3abaf5e2899ae9253597 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:10:54 +0000 Subject: [PATCH 010/139] fix(data): inject DatabaseCleaner so core:data needs no Room on classpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix wasn't enough: AuthRepositoryImpl injected WorkTrackDatabase as a constructor parameter, and merely referencing that type forces the compiler to load its RoomDatabase supertype — which isn't on core:data's classpath (core:database exposes Room only as implementation). So ":core:data:compileDebugKotlin" still failed with "Cannot access androidx.room.RoomDatabase which is a supertype of WorkTrackDatabase". Replace the WorkTrackDatabase injection with a new @Singleton DatabaseCleaner (in :core:database, where Room is visible) that owns the clearAllTables() call and its background dispatch. core:data now injects DatabaseCleaner — whose only supertype is Any — and references no Room type at all. Removes the now-unused DispatcherProvider and withContext from the repository and the extension-function stopgap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- .../data/repository/AuthRepositoryImpl.kt | 16 +++++-------- .../core/database/DatabaseCleaner.kt | 24 +++++++++++++++++++ .../core/database/DatabaseExtensions.kt | 14 ----------- 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt delete mode 100644 core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt index decc0fb..fa0a963 100644 --- a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt @@ -1,14 +1,12 @@ package app.worktrack.core.data.repository -import app.worktrack.core.common.coroutines.DispatcherProvider import app.worktrack.core.common.result.AppError import app.worktrack.core.common.result.AppResult import app.worktrack.core.common.result.map import app.worktrack.core.common.result.onFailure import app.worktrack.core.common.result.onSuccess import app.worktrack.core.data.mapper.toSession -import app.worktrack.core.database.WorkTrackDatabase -import app.worktrack.core.database.clearAllTenantData +import app.worktrack.core.database.DatabaseCleaner import app.worktrack.core.datastore.SessionStore import app.worktrack.core.domain.repository.AuthRepository import app.worktrack.core.model.UserSession @@ -23,15 +21,15 @@ import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext @Singleton class AuthRepositoryImpl @Inject constructor( private val firebaseAuth: FirebaseAuth, private val api: WorkTrackApi, private val sessionStore: SessionStore, - private val database: WorkTrackDatabase, - private val dispatchers: DispatcherProvider, + // DatabaseCleaner (not WorkTrackDatabase) so this module needs no Room on + // its classpath; see DatabaseCleaner's doc for the rationale. + private val databaseCleaner: DatabaseCleaner, ) : AuthRepository { override val session: Flow = sessionStore.session @@ -79,10 +77,8 @@ class AuthRepositoryImpl @Inject constructor( override suspend fun signOut() { firebaseAuth.signOut() sessionStore.clear() - withContext(dispatchers.io) { - // Tenant data never survives a sign-out on shared devices. - database.clearAllTenantData() - } + // Tenant data never survives a sign-out on shared devices. + databaseCleaner.clearAllTenantData() } private fun invalidCredentials() = AppError.Business( diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt new file mode 100644 index 0000000..a7ad8c7 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.database + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Wipes all local tenant data on sign-out so nothing survives on shared devices. + * + * This class exists so that downstream modules (e.g. :core:data) can trigger a + * full wipe WITHOUT depending on Room: they inject DatabaseCleaner — whose only + * supertype is Any — instead of WorkTrackDatabase, whose RoomDatabase supertype + * would otherwise need to be on their compile classpath. + */ +@Singleton +class DatabaseCleaner @Inject constructor( + private val database: WorkTrackDatabase, +) { + /** clearAllTables() is blocking/@WorkerThread; run it off the main thread. */ + suspend fun clearAllTenantData() = withContext(Dispatchers.IO) { + database.clearAllTables() + } +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt deleted file mode 100644 index 007e602..0000000 --- a/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseExtensions.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.worktrack.core.database - -/** - * Wipes every tenant table. Called on sign-out so tenant data never survives on - * shared devices. Kept here (not in :core:data) so that Room's RoomDatabase - * supertype — where clearAllTables() is declared — stays encapsulated within - * this module and is not leaked onto downstream classpaths. - * - * clearAllTables() is a blocking, @WorkerThread call; invoke it from a - * background dispatcher (the repository wraps this in withContext(io)). - */ -fun WorkTrackDatabase.clearAllTenantData() { - clearAllTables() -} From 68a1f84b41dd4610a4e43b0ead63359e0e512e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:15:23 +0000 Subject: [PATCH 011/139] fix(auth): lazy FirebaseAuth so app launches without google-services.json The build succeeds and installs, but the app crashed on startup with "Default FirebaseApp is not initialized" when no google-services.json is present: provideFirebaseAuth() calls FirebaseAuth.getInstance() eagerly while Hilt builds the graph in Application.onCreate. Inject dagger.Lazy in AuthRepositoryImpl and FirebaseAuthTokenProvider so getInstance() is deferred until an actual auth operation (sign-in / token fetch). The app now launches to the localized login screen even without Firebase configured; sign-in fails gracefully as an AppError instead of a fatal crash. Also expand the README Firebase setup steps, including the debug-build package-name gotcha (app.worktrack.debug) needed when registering the Android app and downloading google-services.json. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- README.md | 26 ++++++++++++++++--- .../data/auth/FirebaseAuthTokenProvider.kt | 11 +++++--- .../data/repository/AuthRepositoryImpl.kt | 8 +++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3b33ef6..9f2067f 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,28 @@ Prerequisites: JDK 17+, Android SDK 35. The Gradle wrapper is pinned (8.9). ./gradlew test # JVM unit tests (domain/common) ``` -Firebase setup (one-time): create a Firebase project, enable Email/Password -authentication, then place `google-services.json` in `app/` (the Google Services -plugin is applied automatically when the file exists). Debug builds point the API -at the local Functions emulator (`app/build.gradle.kts` → `API_BASE_URL`). +### Firebase setup (required to run the app) + +The app authenticates with Firebase, so it needs a `google-services.json`. Without +it the app still launches to the login screen, but sign-in fails. To wire it up: + +1. Create a Firebase project at . +2. Add Android app(s) to it. **The debug build's application id is + `app.worktrack.debug`** (the `.debug` suffix is added by the debug build type), + so register that package name to run debug builds. Add `app.worktrack` too for + release builds — both clients end up in the same `google-services.json`. +3. Download `google-services.json` and put it in the **`app/`** directory + (`WorkTrack/app/google-services.json`). The Google Services Gradle plugin is + applied automatically when the file is present (see the bottom of + `app/build.gradle.kts`), which generates the default `FirebaseOptions` that + `FirebaseApp` initializes from at startup. +4. In the Firebase console, enable **Authentication → Sign-in method → + Email/Password**. +5. Rebuild and run. + +The file is git-ignored (it's per-environment config). Debug builds point the API +at the local Functions emulator (`app/build.gradle.kts` → `API_BASE_URL`); run the +backend emulator (see below) and provision a tenant to sign in end-to-end. ## Backend diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt index ab51117..663a9cc 100644 --- a/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt +++ b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt @@ -2,21 +2,24 @@ package app.worktrack.core.data.auth import app.worktrack.core.network.auth.AuthTokenProvider import com.google.firebase.auth.FirebaseAuth +import dagger.Lazy import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.tasks.await @Singleton class FirebaseAuthTokenProvider @Inject constructor( - private val firebaseAuth: FirebaseAuth, + // Lazy so building the OkHttp/Retrofit graph never forces FirebaseAuth init. + private val firebaseAuth: Lazy, ) : AuthTokenProvider { override suspend fun idToken(forceRefresh: Boolean): String? = try { - firebaseAuth.currentUser?.getIdToken(forceRefresh)?.await()?.token + firebaseAuth.get().currentUser?.getIdToken(forceRefresh)?.await()?.token } catch (_: Exception) { - // Offline or revoked: callers treat null as "no credential"; the API - // responds 401 and the UI routes to re-authentication if needed. + // Offline, revoked, or Firebase not configured: callers treat null as + // "no credential"; the API responds 401 and the UI routes to + // re-authentication if needed. null } } diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt index fa0a963..0829435 100644 --- a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt @@ -16,6 +16,7 @@ import com.google.firebase.FirebaseNetworkException import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthInvalidUserException +import dagger.Lazy import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException @@ -24,7 +25,10 @@ import kotlinx.coroutines.tasks.await @Singleton class AuthRepositoryImpl @Inject constructor( - private val firebaseAuth: FirebaseAuth, + // Lazy so FirebaseAuth.getInstance() is NOT called while the Hilt graph is + // built at app launch. Without it, a missing/invalid google-services.json + // would crash the app on startup instead of failing only at sign-in. + private val firebaseAuthProvider: Lazy, private val api: WorkTrackApi, private val sessionStore: SessionStore, // DatabaseCleaner (not WorkTrackDatabase) so this module needs no Room on @@ -32,6 +36,8 @@ class AuthRepositoryImpl @Inject constructor( private val databaseCleaner: DatabaseCleaner, ) : AuthRepository { + private val firebaseAuth: FirebaseAuth get() = firebaseAuthProvider.get() + override val session: Flow = sessionStore.session override suspend fun signIn(email: String, password: String): AppResult { From fced409520b8fd4b4ef71304cc9e521b05d611b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:35:28 +0000 Subject: [PATCH 012/139] feat(web): manager portal (React) + backend endpoints it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web admin console for managers/HR/branch leads — React 18 + TypeScript + Vite, Dari default with Pashto/English, RTL-first, Solar Hijri calendar throughout. Consumes the same /v1 REST API as the Android app. Portal (web/): - Firebase email/password login gated to manager roles (employees and kiosks rejected); GET /me resolves roles + tenant - Dashboard: today's KPIs (active/present/absent/on-leave/late/half-day/ pending-leave/attendance-rate) + 7-day Solar Hijri attendance trend - Employees: branch-scoped directory, search, add-employee form - Attendance monitoring: per-day live board with status, first-in, worked hours, lateness; Shamsi date picker - Leave approvals: pending queue with approve/reject (note required) - Foundation: typed API client (bearer token refresh + RFC 7807), TanStack Query hooks, AuthProvider with client-side RBAC gating, i18n provider (Dari/Pashto/English + Eastern digits), Solar Hijri converter ported from the Android core, CSS design system (teal, logical properties for RTL), sidebar layout. tsc + vite build pass. Backend (needed by the portal): - GET /employees (branch-scoped, paginated), GET /employees/:id, POST/PUT /employees (employees:read / employees:write) - GET /analytics/kpis, GET /analytics/attendance-trend - GET /attendance/overview (manager live board) - Composite employee indexes; wired into app.ts; typecheck passes Firebase Hosting configured (serves web/dist, rewrites /v1/** to the api function). README + web/README document setup and deploy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- README.md | 1 + backend/firebase.json | 9 + backend/firestore.indexes.json | 17 + backend/functions/src/app.ts | 4 + backend/functions/src/routes/analytics.ts | 113 + backend/functions/src/routes/attendance.ts | 56 + backend/functions/src/routes/employees.ts | 197 ++ web/.env.example | 11 + web/.gitignore | 6 + web/README.md | 48 + web/index.html | 12 + web/package-lock.json | 2810 ++++++++++++++++++++ web/package.json | 27 + web/src/App.tsx | 32 + web/src/api/client.ts | 102 + web/src/api/hooks.ts | 80 + web/src/api/types.ts | 121 + web/src/auth/AuthProvider.tsx | 124 + web/src/auth/LoginPage.tsx | 87 + web/src/firebase.ts | 13 + web/src/i18n/LocaleProvider.tsx | 96 + web/src/i18n/strings.ts | 288 ++ web/src/main.tsx | 28 + web/src/pages/AttendancePage.tsx | 107 + web/src/pages/DashboardPage.tsx | 92 + web/src/pages/EmployeesPage.tsx | 220 ++ web/src/pages/LeavePage.tsx | 98 + web/src/shamsi/solarHijri.ts | 125 + web/src/styles.css | 489 ++++ web/src/ui/Layout.tsx | 68 + web/src/ui/components.tsx | 64 + web/src/vite-env.d.ts | 13 + web/tsconfig.app.json | 24 + web/tsconfig.json | 7 + web/tsconfig.node.json | 17 + web/vite.config.ts | 15 + 36 files changed, 5621 insertions(+) create mode 100644 backend/functions/src/routes/analytics.ts create mode 100644 backend/functions/src/routes/employees.ts create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/hooks.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/auth/AuthProvider.tsx create mode 100644 web/src/auth/LoginPage.tsx create mode 100644 web/src/firebase.ts create mode 100644 web/src/i18n/LocaleProvider.tsx create mode 100644 web/src/i18n/strings.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/AttendancePage.tsx create mode 100644 web/src/pages/DashboardPage.tsx create mode 100644 web/src/pages/EmployeesPage.tsx create mode 100644 web/src/pages/LeavePage.tsx create mode 100644 web/src/shamsi/solarHijri.ts create mode 100644 web/src/styles.css create mode 100644 web/src/ui/Layout.tsx create mode 100644 web/src/ui/components.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/README.md b/README.md index 9f2067f..408372d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ calendar — see `docs/10-localization-afghanistan.md`. | `app/`, `core/`, `feature/` | Android app — Kotlin, Jetpack Compose (M3), MVVM + Clean Architecture, Hilt, Room, WorkManager, offline-first sync | | `build-logic/` | Gradle convention plugins shared by all modules | | `backend/` | Firebase backend — REST API v1 on Cloud Functions (TypeScript/Express), Firestore rules and indexes | +| `web/` | Manager portal (web admin) — React + TypeScript + Vite, Dari/Pashto/English, Solar Hijri (see `web/README.md`) | ## Design documentation diff --git a/backend/firebase.json b/backend/firebase.json index 6d10c19..23dfa2d 100644 --- a/backend/firebase.json +++ b/backend/firebase.json @@ -8,10 +8,19 @@ "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, + "hosting": { + "public": "../web/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { "source": "/v1/**", "function": "api" }, + { "source": "**", "destination": "/index.html" } + ] + }, "emulators": { "auth": { "port": 9099 }, "functions": { "port": 5001 }, "firestore": { "port": 8080 }, + "hosting": { "port": 5000 }, "ui": { "enabled": true } } } diff --git a/backend/firestore.indexes.json b/backend/firestore.indexes.json index 4a3fddf..1a52dca 100644 --- a/backend/firestore.indexes.json +++ b/backend/firestore.indexes.json @@ -80,6 +80,23 @@ { "fieldPath": "employeeId", "order": "ASCENDING" }, { "fieldPath": "updatedAt", "order": "ASCENDING" } ] + }, + { + "collectionGroup": "employees", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "branchId", "order": "ASCENDING" }, + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "__name__", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "employees", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "updatedAt", "order": "ASCENDING" } + ] } ], "fieldOverrides": [] diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts index 52ffbec..97f788e 100644 --- a/backend/functions/src/app.ts +++ b/backend/functions/src/app.ts @@ -7,6 +7,8 @@ import { attendanceRouter } from "./routes/attendance"; import { leaveRouter } from "./routes/leave"; import { payslipsRouter } from "./routes/payslips"; import { announcementsRouter } from "./routes/announcements"; +import { employeesRouter } from "./routes/employees"; +import { analyticsRouter } from "./routes/analytics"; import { syncRouter } from "./routes/sync"; /** @@ -28,10 +30,12 @@ export function createApp(): express.Express { const v1 = express.Router(); v1.use(requireAuth); v1.use("/me", meRouter); + v1.use("/employees", employeesRouter); v1.use("/attendance", attendanceRouter); v1.use("/leave", leaveRouter); v1.use("/payslips", payslipsRouter); v1.use("/announcements", announcementsRouter); + v1.use("/analytics", analyticsRouter); v1.use("/sync", syncRouter); app.use("/v1", v1); diff --git a/backend/functions/src/routes/analytics.ts b/backend/functions/src/routes/analytics.ts new file mode 100644 index 0000000..fd9b865 --- /dev/null +++ b/backend/functions/src/routes/analytics.ts @@ -0,0 +1,113 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { tenant } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; + +export const analyticsRouter = Router(); + +/** + * Dashboard KPIs for a given day (defaults to today, server timezone). + * + * At small/medium tenant sizes this reads attendanceDays + counts directly. For + * 100k-employee tenants these figures are served from the BigQuery rollup + * instead (see docs/02); the response shape stays identical so the portal is + * unaffected by that swap. + */ +analyticsRouter.get( + "/kpis", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date ?? "")) + ? String(req.query.date) + : new Date().toISOString().slice(0, 10); + + const [employeesSnap, daysSnap, pendingLeaveSnap] = await Promise.all([ + tenant(auth.companyId, "employees").where("status", "==", "ACTIVE").count().get(), + tenant(auth.companyId, "attendanceDays").where("date", "==", date).get(), + tenant(auth.companyId, "leaveRequests").where("status", "==", "PENDING").count().get(), + ]); + + const activeEmployees = employeesSnap.data().count; + + let present = 0; + let late = 0; + let onLeave = 0; + let halfDay = 0; + for (const doc of daysSnap.docs) { + const day = doc.data() as { status: string; lateMinutes?: number }; + switch (day.status) { + case "PRESENT": + present += 1; + if ((day.lateMinutes ?? 0) > 0) late += 1; + break; + case "HALF_DAY": + halfDay += 1; + break; + case "LEAVE": + onLeave += 1; + break; + default: + break; + } + } + const marked = present + halfDay + onLeave; + const absent = Math.max(0, activeEmployees - marked); + + res.json({ + data: { + date, + activeEmployees, + present, + halfDay, + late, + onLeave, + absent, + pendingLeaveRequests: pendingLeaveSnap.data().count, + attendanceRate: + activeEmployees > 0 ? Math.round(((present + halfDay) / activeEmployees) * 100) : 0, + }, + }); + }), +); + +/** + * 7-point attendance trend ending on `date` (present count per day). Powers the + * dashboard sparkline. Solar Hijri labels are formatted client-side. + */ +analyticsRouter.get( + "/attendance-trend", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const end = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date ?? "")) + ? new Date(`${String(req.query.date)}T00:00:00Z`) + : new Date(); + + const dates: string[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(end.getTime() - i * 86_400_000); + dates.push(d.toISOString().slice(0, 10)); + } + + const snap = await tenant(auth.companyId, "attendanceDays") + .where("date", ">=", dates[0]) + .where("date", "<=", dates[dates.length - 1]) + .get(); + + const presentByDate = new Map(dates.map((d) => [d, 0])); + for (const doc of snap.docs) { + const day = doc.data() as { date: string; status: string }; + if (day.status === "PRESENT" || day.status === "HALF_DAY") { + presentByDate.set(day.date, (presentByDate.get(day.date) ?? 0) + 1); + } + } + + res.json({ + data: { + points: dates.map((d) => ({ date: d, present: presentByDate.get(d) ?? 0 })), + }, + }); + }), +); diff --git a/backend/functions/src/routes/attendance.ts b/backend/functions/src/routes/attendance.ts index 6ce5eaf..ef2007c 100644 --- a/backend/functions/src/routes/attendance.ts +++ b/backend/functions/src/routes/attendance.ts @@ -37,6 +37,62 @@ attendanceRouter.post( }), ); +/** + * Manager live board: every employee's attendance status for one day, joined + * with employee name/branch. Branch managers are scoped to their branch(es). + */ +attendanceRouter.get( + "/overview", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date ?? "")) + ? String(req.query.date) + : new Date().toISOString().slice(0, 10); + + const companyWide = + auth.roles.includes("COMPANY_ADMIN") || + auth.roles.includes("HR_ADMIN") || + auth.roles.includes("AUDITOR") || + auth.roles.includes("SUPER_ADMIN"); + const branchFilter = + (req.query.branchId ? String(req.query.branchId) : null) ?? + (!companyWide ? auth.branchIds[0] ?? null : null); + + let employeesQuery = tenant(auth.companyId, "employees").where("status", "==", "ACTIVE"); + if (branchFilter) { + employeesQuery = employeesQuery.where("branchId", "==", branchFilter); + } + const [employeesSnap, daysSnap] = await Promise.all([ + employeesQuery.limit(500).get(), + tenant(auth.companyId, "attendanceDays").where("date", "==", date).get(), + ]); + + const dayByEmployee = new Map>(); + for (const doc of daysSnap.docs) { + const day = doc.data() as { employeeId: string }; + dayByEmployee.set(day.employeeId, day as Record); + } + + const rows = employeesSnap.docs.map((doc) => { + const emp = doc.data() as { firstName: string; lastName: string; branchId?: string | null }; + const day = dayByEmployee.get(doc.id); + return { + employeeId: doc.id, + employeeName: `${emp.firstName} ${emp.lastName}`.trim(), + branchId: emp.branchId ?? null, + status: (day?.status as string | undefined) ?? "ABSENT", + firstInAt: toIso((day?.firstInAt as Timestamp | undefined) ?? null), + lastOutAt: toIso((day?.lastOutAt as Timestamp | undefined) ?? null), + workedMinutes: (day?.workedMinutes as number | undefined) ?? 0, + lateMinutes: (day?.lateMinutes as number | undefined) ?? 0, + }; + }); + + res.json({ data: { date, rows } }); + }), +); + /** Attendance day projections for a date window (self, or any employee with attendance:read). */ attendanceRouter.get( "/days", diff --git a/backend/functions/src/routes/employees.ts b/backend/functions/src/routes/employees.ts new file mode 100644 index 0000000..325325c --- /dev/null +++ b/backend/functions/src/routes/employees.ts @@ -0,0 +1,197 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import type { Query } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; + +export const employeesRouter = Router(); + +interface EmployeeDoc { + employeeCode: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + avatarUrl?: string | null; + branchId?: string | null; + departmentId?: string | null; + positionId?: string | null; + managerId?: string | null; + employmentType: string; + joinDate: string; + status: string; + updatedAt: Timestamp; +} + +function employeeToDto(id: string, companyId: string, doc: EmployeeDoc): Record { + return { + id, + companyId, + employeeCode: doc.employeeCode, + firstName: doc.firstName, + lastName: doc.lastName, + email: doc.email, + phone: doc.phone ?? null, + avatarUrl: doc.avatarUrl ?? null, + branchId: doc.branchId ?? null, + departmentId: doc.departmentId ?? null, + positionId: doc.positionId ?? null, + managerId: doc.managerId ?? null, + employmentType: doc.employmentType, + joinDate: doc.joinDate, + status: doc.status, + updatedAt: toIso(doc.updatedAt), + }; +} + +/** + * Employee directory. Branch-scoped managers see only their branches; company/ + * HR admins see everyone. Cursor pagination on the document id (employeeCode + * order would need a composite index; id order is stable and index-free). + */ +employeesRouter.get( + "/", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const limit = Math.min(Number.parseInt(String(req.query.limit ?? "50"), 10) || 50, 100); + const cursor = req.query.cursor ? String(req.query.cursor) : null; + const branchFilter = req.query.branchId ? String(req.query.branchId) : null; + const statusFilter = req.query.status ? String(req.query.status) : null; + + let query: Query = tenant(auth.companyId, "employees"); + + // A branch manager is confined to the branches on their token claim. + const companyWide = + auth.roles.includes("COMPANY_ADMIN") || + auth.roles.includes("HR_ADMIN") || + auth.roles.includes("AUDITOR") || + auth.roles.includes("PAYROLL_ADMIN") || + auth.roles.includes("SUPER_ADMIN"); + + const effectiveBranch = branchFilter ?? (!companyWide ? auth.branchIds[0] ?? null : null); + if (effectiveBranch) { + query = query.where("branchId", "==", effectiveBranch); + } + if (statusFilter) { + query = query.where("status", "==", statusFilter); + } + + query = query.orderBy("__name__").limit(limit); + if (cursor) { + query = query.startAfter(cursor); + } + + const snapshot = await query.get(); + const data = snapshot.docs.map((doc) => + employeeToDto(doc.id, auth.companyId, doc.data() as EmployeeDoc), + ); + const last = snapshot.docs[snapshot.docs.length - 1]; + + res.json({ + data, + meta: { + cursor: snapshot.size === limit && last ? last.id : null, + hasMore: snapshot.size === limit, + }, + }); + }), +); + +employeesRouter.get( + "/:id", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const doc = await tenant(auth.companyId, "employees").doc(req.params.id).get(); + if (!doc.exists) { + throw ApiError.notFound("Employee not found"); + } + res.json({ data: employeeToDto(doc.id, auth.companyId, doc.data() as EmployeeDoc) }); + }), +); + +const employeeWriteSchema = z.object({ + employeeCode: z.string().min(1).max(40), + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + email: z.string().email(), + phone: z.string().max(40).nullish(), + branchId: z.string().nullish(), + departmentId: z.string().nullish(), + positionId: z.string().nullish(), + managerId: z.string().nullish(), + employmentType: z.enum(["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]), + joinDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + status: z.enum(["ACTIVE", "ON_LEAVE", "SUSPENDED", "EXITED"]).default("ACTIVE"), +}); + +employeesRouter.post( + "/", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, employeeWriteSchema); + const id = ulid(); + const doc: EmployeeDoc = { + ...payload, + phone: payload.phone ?? null, + branchId: payload.branchId ?? null, + departmentId: payload.departmentId ?? null, + positionId: payload.positionId ?? null, + managerId: payload.managerId ?? null, + avatarUrl: null, + updatedAt: nowTimestamp(), + }; + await tenant(auth.companyId, "employees").doc(id).create(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.create", + resourceType: "employees", + resourceId: id, + after: { employeeCode: payload.employeeCode, email: payload.email }, + }); + res.status(201).json({ data: employeeToDto(id, auth.companyId, doc) }); + }), +); + +employeesRouter.put( + "/:id", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, employeeWriteSchema); + const ref = tenant(auth.companyId, "employees").doc(req.params.id); + const existing = await ref.get(); + if (!existing.exists) { + throw ApiError.notFound("Employee not found"); + } + const doc: EmployeeDoc = { + ...payload, + phone: payload.phone ?? null, + branchId: payload.branchId ?? null, + departmentId: payload.departmentId ?? null, + positionId: payload.positionId ?? null, + managerId: payload.managerId ?? null, + avatarUrl: (existing.data() as EmployeeDoc).avatarUrl ?? null, + updatedAt: nowTimestamp(), + }; + await ref.set(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.update", + resourceType: "employees", + resourceId: req.params.id, + before: employeeToDto(req.params.id, auth.companyId, existing.data() as EmployeeDoc), + after: employeeToDto(req.params.id, auth.companyId, doc), + }); + res.json({ data: employeeToDto(req.params.id, auth.companyId, doc) }); + }), +); diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..08964b0 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,11 @@ +# Copy to .env.local and fill in from your Firebase project settings. +# All VITE_-prefixed vars are exposed to the client bundle (public config only). + +# REST API base URL. Local Functions emulator by default: +VITE_API_BASE_URL=http://127.0.0.1:5001/worktrack-dev/us-central1/api/v1 + +# Firebase web app config (Project settings -> Your apps -> Web app): +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_APP_ID= diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3b7a417 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +*.local +*.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..4fd3e80 --- /dev/null +++ b/web/README.md @@ -0,0 +1,48 @@ +# WorkTrack Manager Portal (پورتال مدیر) + +Web admin console for managers, HR, payroll, and branch/team leads. React 18 + +TypeScript + Vite, consuming the same `/v1` REST API as the Android app. Dari is +the default language (full Pashto + English), RTL-first, with the Solar Hijri +calendar throughout. + +## Features (P0) + +- **Login** — Firebase email/password; only manager roles are admitted + (`COMPANY_ADMIN`, `HR_ADMIN`, `PAYROLL_ADMIN`, `BRANCH_MANAGER`, `TEAM_LEAD`, + `AUDITOR`). Employees/kiosks are rejected. +- **Dashboard** — today's KPIs (active, present, absent, on-leave, late, half-day, + pending leave, attendance rate) + a 7-day Solar Hijri attendance trend. +- **Employees** — directory (branch-scoped for branch managers), search, and an + add-employee form (`employees:write`). +- **Attendance monitoring** — per-day live board of every employee's status, + first-in time, worked hours, and lateness; date picker in Solar Hijri. +- **Leave approvals** — pending-request queue with approve/reject (rejection + requires a note, enforced server-side too). + +RBAC gates the sidebar and actions client-side for UX; the server is authoritative. + +## Develop + +```bash +npm install +cp .env.example .env.local # fill in Firebase web config + API base URL +npm run dev # http://localhost:5173 +npm run build # tsc + vite build -> dist/ +``` + +`.env.local` needs your Firebase **web app** config (Project settings → Your apps → +Web) and `VITE_API_BASE_URL`. For local development point it at the Functions +emulator, e.g. `http://127.0.0.1:5001//us-central1/api/v1`. + +## Deploy (Firebase Hosting) + +Hosting is configured in `../backend/firebase.json` (serves `web/dist`, rewrites +`/v1/**` to the `api` function and everything else to the SPA): + +```bash +npm run build +cd ../backend && firebase deploy --only hosting +``` + +When served from Hosting you can set `VITE_API_BASE_URL=/v1` so the SPA and API +share an origin (no CORS). diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..666e446 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + WorkTrack — پورتال مدیر + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a5c3c3a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2810 @@ +{ + "name": "worktrack-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worktrack-admin", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "firebase": "^10.12.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.25.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.3.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.8.tgz", + "integrity": "sha512-CVnHcS4iRJPqtIDc411+UmFldk0ShSK3OB+D0bKD8Ck5Vro6dbK5+APZpkuWpbfdL359DIQUnAaMLE+zs/PVyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.14.tgz", + "integrity": "sha512-unRVY6SvRqfNFIAA/kwl4vK+lvQAL2HVcgu9zTrUtTyYDmtIt/lOuHJynBMYEgLnKm39YKBDhtqdapP2e++ASw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.8", + "@firebase/analytics-types": "0.8.2", + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.2.tgz", + "integrity": "sha512-EnzNNLh+9/sJsimsA/FGqzakmrAUKLeJvjRHlg8df1f97NLUlFidk9600y0ZgWOp3CAxn6Hjtk+08tixlUOWyw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.13.tgz", + "integrity": "sha512-OZiDAEK/lDB6xy/XzYAyJJkaDqmQ+BCtOEPLqFvxWKUz5JbBmej7IiiRHdtiIOD/twW7O5AxVsfaaGA/V1bNsA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.8.8.tgz", + "integrity": "sha512-O49RGF1xj7k6BuhxGpHmqOW5hqBIAEbt2q6POW0lIywx7emYtzPDeQI+ryQpC4zbKX646SoVZ711TN1DBLNSOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.15.tgz", + "integrity": "sha512-zFIvIFFNqDXpOT2huorz9cwf56VT3oJYRFjSFYdSbGYEJYEaXjLJbfC79lx/zjx4Fh+yuN8pry3TtvwaevrGbg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.8.8", + "@firebase/app-check-types": "0.5.2", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.2.tgz", + "integrity": "sha512-FSOEzTzL5bLUbD2co3Zut46iyPWML6xc4x+78TeaXMSuJap5QObfb+rVvZJtla3asN4RwU7elaQaduP+HFizDA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.2.43", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.43.tgz", + "integrity": "sha512-HM96ZyIblXjAC7TzE8wIk2QhHlSvksYkQ4Ukh1GmEenzkucSNUmUX4QvoKrqeWsLEQ8hdcojABeCV8ybVyZmeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.10.13", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.14.tgz", + "integrity": "sha512-2eczCSqBl1KUPJacZlFpQayvpilg3dxXLy9cSMTKtQMTQSmondUtPI47P3ikH3bQAXhzKLOE+qVxJ3/IRtu9pw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.7.9", + "@firebase/auth-types": "0.12.2", + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-compat/node_modules/@firebase/auth": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.7.9.tgz", + "integrity": "sha512-yLD5095kVgDw965jepMyUrIgDklD6qH/BZNHeKOgvu7pchOKNjVM+zQoOVYJIKWMWOWBq8IRNVU6NXzBbozaJg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.2.tgz", + "integrity": "sha512-qsEBaRMoGvHO10unlDJhaKSuPn4pyoTtlQuP1ghZfzB6rNQPuhp/N/DcFZxm9i4v0SogjCbf9reWupwIvfmH6w==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.1.0.tgz", + "integrity": "sha512-vSe5s8dY13ilhLnfY0eYRmQsdTbH7PUFZtBbqU6JVX/j8Qp9A6G5gG6//ulbX9/1JFOF1IWNOne9c8S/DOCJaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.3.tgz", + "integrity": "sha512-NwVU+JPZ/3bhvNSJMCSzfcBZZg8SUGyzZ2T0EW3/bkUeefCyzMISSt/TTIfEHc8cdyXGlMqfGe3/62u9s74UEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "@firebase/webchannel-wrapper": "1.0.1", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "engines": { + "node": ">=10.10.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.38", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.38.tgz", + "integrity": "sha512-GoS0bIMMkjpLni6StSwRJarpu2+S5m346Na7gr9YZ/BZ/W3/8iHGNr9PxC+f0rNZXqS4fGRn88pICjrZEgbkqQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/firestore": "4.7.3", + "@firebase/firestore-types": "3.0.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.2.tgz", + "integrity": "sha512-wp1A+t5rI2Qc/2q7r2ZpjUXkRVPtGMd6zCLsiWurjsQpqPgFin3AhNibKcIzoF2rnToNa/XYtyWXuifjOOwDgg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.11.8.tgz", + "integrity": "sha512-Lo2rTPDn96naFIlSZKVd1yvRRqqqwiJk7cf9TZhUerwnPKgBzXy+aHE22ry+6EjCaQusUoNai6mU6p+G8QZT1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.14.tgz", + "integrity": "sha512-dZ0PKOKQFnOlMfcim39XzaXonSuPPAVuzpqA4ONTIdyaJK/OnBaIEVs/+BH4faa1a2tLeR+Jy15PKqDRQoNIJw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/functions": "0.11.8", + "@firebase/functions-types": "0.6.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.2.tgz", + "integrity": "sha512-0KiJ9lZ28nS2iJJvimpY4nNccV21rkQyor5Iheu/nq8aKXJqtJdeSlZDspjPSBBiHRzo7/GMUttegnsEITqR+w==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.9.tgz", + "integrity": "sha512-hlT7AwCiKghOX3XizLxXOsTFiFCQnp/oj86zp1UxwDGmyzsyoxtX+UIZyVyH/oBF5+XtblFG9KZzZQ/h+dpy+Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.9.tgz", + "integrity": "sha512-2lfdc6kPXR7WaL4FCQSQUhXcPbI7ol3wF+vkgtU25r77OxPf8F/VmswQ7sgIkBBWtymn5ZF20TIKtnOj9rjb6w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/installations-types": "0.5.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.2.tgz", + "integrity": "sha512-que84TqGRZJpJKHBlF2pkvc1YcXrtEDOVGiDjovP/a3s6W4nlbohGXEsBJo0JCeeg/UG9A+DEZVDUV9GpklUzA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.12", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.12.tgz", + "integrity": "sha512-6q0pbzYBJhZEtUoQx7hnPhZvAbuMNuBXKQXOx2YlWhSrlv9N1m0ZzlNpBbu/ItTzrwNKTibdYzUyaaxdWLg+4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.12.tgz", + "integrity": "sha512-pKsiUVZrbmRgdImYqhBNZlkKJbqjlPkVdQRZGRbkTyX4OSGKR0F/oJeCt1a8jEg5UnBp4fdVwSWSp4DuCovvEQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/messaging": "0.12.12", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.2.tgz", + "integrity": "sha512-l68HXbuD2PPzDUOFb3aG+nZj5KA3INcPwlocwLZOzPp9rFM9yeuI9YLl6DQfguTX5eAGxO0doTR+rDLDvQb5tA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.9.tgz", + "integrity": "sha512-PnVaak5sqfz5ivhua+HserxTJHtCar/7zM0flCX6NkzBNzJzyzlH4Hs94h2Il0LQB99roBqoE5QT1JqWqcLJHQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.9.tgz", + "integrity": "sha512-dNl95IUnpsu3fAfYBZDCVhXNkASE0uo4HYaEPd2/PKscfTvsgqFAOxfAXzBEDOnynDWiaGUnb5M1O00JQ+3FXA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/performance": "0.6.9", + "@firebase/performance-types": "0.2.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.2.tgz", + "integrity": "sha512-gVq0/lAClVH5STrIdKnHnCo2UcPLjJlDUoEB/tB4KM+hAeHUxWKnpT0nemUPvxZ5nbdY/pybeyMe8Cs29gEcHA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.4.9.tgz", + "integrity": "sha512-EO1NLCWSPMHdDSRGwZ73kxEEcTopAxX1naqLJFNApp4hO8WfKfmEpmjxmP5TrrnypjIf2tUkYaKsfbEA7+AMmA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.9.tgz", + "integrity": "sha512-AxzGpWfWFYejH2twxfdOJt5Cfh/ATHONegTd/a0p5flEzsD5JsxXgfkFToop+mypEL3gNwawxrxlZddmDoNxyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/remote-config": "0.4.9", + "@firebase/remote-config-types": "0.3.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.3.2.tgz", + "integrity": "sha512-0BC4+Ud7y2aPTyhXJTMTFfrGGLqdYXrUB9sJVAB8NiqJswDTc4/2qrE/yfUbnQJhbSi6ZaTTBKyG3n1nplssaA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.2.tgz", + "integrity": "sha512-fxuJnHshbhVwuJ4FuISLu+/76Aby2sh+44ztjF2ppoe0TELIDxPW6/r1KGlWYt//AD0IodDYYA8ZTN89q8YqUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.12.tgz", + "integrity": "sha512-hA4VWKyGU5bWOll+uwzzhEMMYGu9PlKQc1w4DWxB3aIErWYzonrZjF0icqNQZbwKNIdh8SHjZlFeB2w6OSsjfg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/storage": "0.13.2", + "@firebase/storage-types": "0.8.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.2.tgz", + "integrity": "sha512-0vWu99rdey0g53lA7IShoA2Lol1jfnPovzLDUBuon65K7uKG9G+L5uO05brD9pMw+l4HRFw23ah3GwTGpEav6g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/vertexai-preview": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@firebase/vertexai-preview/-/vertexai-preview-0.0.4.tgz", + "integrity": "sha512-EBSqyu9eg8frQlVU9/HjKtHN7odqbh9MtAcVz3WwHj4gLCLOoN9F/o+oxlq3CxvFrd3CNTZwu6d2mZtVlEInng==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.1.tgz", + "integrity": "sha512-jmEnr/pk0yVkA7mIlHNnxCi+wWzOFUg0WyIotgkKAb2u1J7fAeDBcVNSTjTihbAYNusCLQdW5s9IJ5qwnEufcQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/firebase": { + "version": "10.14.1", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-10.14.1.tgz", + "integrity": "sha512-0KZxU+Ela9rUCULqFsUUOYYkjh7OM1EWdIfG6///MtXd0t2/uUIf0iNV5i0KariMhRQ5jve/OY985nrAXFaZeQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.8", + "@firebase/analytics-compat": "0.2.14", + "@firebase/app": "0.10.13", + "@firebase/app-check": "0.8.8", + "@firebase/app-check-compat": "0.3.15", + "@firebase/app-compat": "0.2.43", + "@firebase/app-types": "0.9.2", + "@firebase/auth": "1.7.9", + "@firebase/auth-compat": "0.5.14", + "@firebase/data-connect": "0.1.0", + "@firebase/database": "1.0.8", + "@firebase/database-compat": "1.0.8", + "@firebase/firestore": "4.7.3", + "@firebase/firestore-compat": "0.3.38", + "@firebase/functions": "0.11.8", + "@firebase/functions-compat": "0.3.14", + "@firebase/installations": "0.6.9", + "@firebase/installations-compat": "0.2.9", + "@firebase/messaging": "0.12.12", + "@firebase/messaging-compat": "0.2.12", + "@firebase/performance": "0.6.9", + "@firebase/performance-compat": "0.2.9", + "@firebase/remote-config": "0.4.9", + "@firebase/remote-config-compat": "0.2.9", + "@firebase/storage": "0.13.2", + "@firebase/storage-compat": "0.3.12", + "@firebase/util": "1.10.0", + "@firebase/vertexai-preview": "0.0.4" + } + }, + "node_modules/firebase/node_modules/@firebase/auth": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.7.9.tgz", + "integrity": "sha512-yLD5095kVgDw965jepMyUrIgDklD6qH/BZNHeKOgvu7pchOKNjVM+zQoOVYJIKWMWOWBq8IRNVU6NXzBbozaJg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.7.tgz", + "integrity": "sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..31154b2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "worktrack-admin", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "WorkTrack manager portal (web admin) — React + TypeScript", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "firebase": "^10.12.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.25.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.3.4" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..91e59e7 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,32 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { useAuth } from "./auth/AuthProvider"; +import { LoginPage } from "./auth/LoginPage"; +import { Layout } from "./ui/Layout"; +import { LoadingState } from "./ui/components"; +import { DashboardPage } from "./pages/DashboardPage"; +import { EmployeesPage } from "./pages/EmployeesPage"; +import { AttendancePage } from "./pages/AttendancePage"; +import { LeavePage } from "./pages/LeavePage"; + +export function App() { + const { status } = useAuth(); + + if (status === "loading") { + return ; + } + if (status === "signedOut") { + return ; + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..99d686a --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,102 @@ +import { auth } from "../firebase"; +import type { Envelope, Problem } from "./types"; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL.replace(/\/$/, ""); + +/** Typed API error carrying the RFC 7807 problem code and any field errors. */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + detail: string, + readonly fieldErrors: Record = {}, + ) { + super(detail); + } + + get isUnauthenticated(): boolean { + return this.status === 401 || this.code === "UNAUTHENTICATED"; + } +} + +function ulid(): string { + // Idempotency key for POSTs; simplicity over sortability is fine client-side. + return ( + Date.now().toString(36) + Math.random().toString(36).slice(2, 12) + ).toUpperCase(); +} + +async function authHeader(forceRefresh = false): Promise> { + const token = await auth.currentUser?.getIdToken(forceRefresh); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +interface RequestOptions { + method?: string; + body?: unknown; + query?: Record; + idempotent?: boolean; +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = "GET", body, query, idempotent } = options; + + const url = new URL(`${BASE_URL}${path}`); + if (query) { + for (const [key, val] of Object.entries(query)) { + if (val !== undefined && val !== null) url.searchParams.set(key, String(val)); + } + } + + const headers: Record = { + Accept: "application/json", + ...(await authHeader()), + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (idempotent) headers["Idempotency-Key"] = ulid(); + + let response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + // One retry with a force-refreshed token to cover ID-token expiry. + if (response.status === 401 && auth.currentUser) { + response = await fetch(url, { + method, + headers: { ...headers, ...(await authHeader(true)) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + if (!response.ok) { + throw await toApiError(response); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +async function toApiError(response: Response): Promise { + let problem: Problem = {}; + try { + problem = (await response.json()) as Problem; + } catch { + // Non-JSON error body; fall through to status-based defaults. + } + return new ApiError( + response.status, + problem.code ?? `HTTP_${response.status}`, + problem.detail ?? problem.title ?? response.statusText, + problem.fieldErrors ?? {}, + ); +} + +export const api = { + get: (path: string, query?: RequestOptions["query"]) => + request>(path, { query }).then((e) => e), + post: (path: string, body: unknown, idempotent = true) => + request>(path, { method: "POST", body, idempotent }), + put: (path: string, body: unknown) => + request>(path, { method: "PUT", body }), +}; diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 0000000..d50ff4b --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,80 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "./client"; +import type { + AttendanceOverviewRow, + Employee, + EmployeeWrite, + Kpis, + LeaveRequest, + TrendPoint, +} from "./types"; + +export function useKpis(date?: string) { + return useQuery({ + queryKey: ["kpis", date ?? "today"], + queryFn: () => api.get("/analytics/kpis", { date }).then((e) => e.data), + }); +} + +export function useAttendanceTrend(date?: string) { + return useQuery({ + queryKey: ["attendance-trend", date ?? "today"], + queryFn: () => + api + .get<{ points: TrendPoint[] }>("/analytics/attendance-trend", { date }) + .then((e) => e.data.points), + }); +} + +export function useAttendanceOverview(date?: string) { + return useQuery({ + queryKey: ["attendance-overview", date ?? "today"], + queryFn: () => + api + .get<{ date: string; rows: AttendanceOverviewRow[] }>("/attendance/overview", { date }) + .then((e) => e.data.rows), + }); +} + +export function useEmployees(params: { cursor?: string; branchId?: string; status?: string }) { + return useQuery({ + queryKey: ["employees", params], + queryFn: () => + api.get("/employees", { + cursor: params.cursor, + branchId: params.branchId, + status: params.status, + limit: 50, + }), + }); +} + +export function useCreateEmployee() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: EmployeeWrite) => api.post("/employees", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), + }); +} + +export function usePendingApprovals() { + return useQuery({ + queryKey: ["leave", "approvals"], + queryFn: () => + api.get("/leave/requests", { scope: "approvals" }).then((e) => e.data), + }); +} + +export function useDecideLeave() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; decision: "APPROVE" | "REJECT"; note?: string | null }) => + api + .post(`/leave/requests/${args.id}/decide`, { + decision: args.decision, + note: args.note ?? null, + }) + .then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["leave", "approvals"] }), + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..0a06875 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,121 @@ +// Wire types mirroring the backend REST API v1 responses. + +export interface Envelope { + data: T; + meta?: { cursor?: string | null; hasMore?: boolean }; +} + +export interface Problem { + type?: string; + title?: string; + status?: number; + code?: string; + detail?: string; + fieldErrors?: Record; +} + +export interface Me { + uid: string; + companyId: string; + companyName: string; + employeeId: string; + displayName: string; + email: string; + avatarUrl: string | null; + roles: string[]; + branchIds: string[]; +} + +export type EmploymentType = "FULL_TIME" | "PART_TIME" | "CONTRACT" | "INTERN"; +export type EmployeeStatus = "ACTIVE" | "ON_LEAVE" | "SUSPENDED" | "EXITED"; + +export interface Employee { + id: string; + companyId: string; + employeeCode: string; + firstName: string; + lastName: string; + email: string; + phone: string | null; + avatarUrl: string | null; + branchId: string | null; + departmentId: string | null; + positionId: string | null; + managerId: string | null; + employmentType: EmploymentType; + joinDate: string; + status: EmployeeStatus; + updatedAt: string; +} + +export interface EmployeeWrite { + employeeCode: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + branchId?: string | null; + departmentId?: string | null; + positionId?: string | null; + managerId?: string | null; + employmentType: EmploymentType; + joinDate: string; + status: EmployeeStatus; +} + +export interface Branch { + id: string; + companyId: string; + name: string; + code: string; + timezone: string; + updatedAt: string; +} + +export interface Kpis { + date: string; + activeEmployees: number; + present: number; + halfDay: number; + late: number; + onLeave: number; + absent: number; + pendingLeaveRequests: number; + attendanceRate: number; +} + +export interface TrendPoint { + date: string; + present: number; +} + +export interface AttendanceOverviewRow { + employeeId: string; + employeeName: string; + branchId: string | null; + status: string; + firstInAt: string | null; + lastOutAt: string | null; + workedMinutes: number; + lateMinutes: number; +} + +export interface LeaveRequest { + id: string; + companyId: string; + employeeId: string; + employeeName: string | null; + leaveTypeId: string; + startDate: string; + endDate: string; + startHalfDay: boolean; + endHalfDay: boolean; + days: number; + reason: string; + status: string; + currentApproverId: string | null; + decidedAt: string | null; + decisionNote: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..fec1344 --- /dev/null +++ b/web/src/auth/AuthProvider.tsx @@ -0,0 +1,124 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + signInWithEmailAndPassword, + signOut as firebaseSignOut, + onAuthStateChanged, +} from "firebase/auth"; +import { auth } from "../firebase"; +import { api, ApiError } from "../api/client"; +import type { Me } from "../api/types"; + +type Status = "loading" | "signedOut" | "signedIn"; + +interface AuthContextValue { + status: Status; + me: Me | null; + signIn: (email: string, password: string) => Promise; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +/** Manager roles allowed into the portal. Employees/kiosks are rejected. */ +const MANAGER_ROLES = new Set([ + "SUPER_ADMIN", + "COMPANY_ADMIN", + "HR_ADMIN", + "PAYROLL_ADMIN", + "BRANCH_MANAGER", + "TEAM_LEAD", + "AUDITOR", +]); + +export class NoManagerAccessError extends Error {} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [me, setMe] = useState(null); + + useEffect(() => { + // Resolve the session on load and whenever Firebase auth state changes + // (e.g. token restored from persistence). GET /me gives roles + tenant. + return onAuthStateChanged(auth, async (user) => { + if (!user) { + setMe(null); + setStatus("signedOut"); + return; + } + try { + const { data } = await api.get("/me"); + if (!data.roles.some((r) => MANAGER_ROLES.has(r))) { + await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + return; + } + setMe(data); + setStatus("signedIn"); + } catch (err) { + // A valid Firebase user with no /me (not provisioned) is signed out. + if (err instanceof ApiError) await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + } + }); + }, []); + + const value = useMemo( + () => ({ + status, + me, + signIn: async (email, password) => { + const cred = await signInWithEmailAndPassword(auth, email.trim(), password); + const { data } = await api.get("/me"); + if (!data.roles.some((r) => MANAGER_ROLES.has(r))) { + await firebaseSignOut(auth); + throw new NoManagerAccessError(); + } + setMe(data); + setStatus("signedIn"); + void cred; + }, + signOut: async () => { + await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + }, + }), + [status, me], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} + +/** Client-side permission check mirroring the server RBAC catalog (UX only). */ +export function useHasPermission(): (permission: string) => boolean { + const { me } = useAuth(); + return (permission: string) => { + if (!me) return false; + if (me.roles.includes("COMPANY_ADMIN") || me.roles.includes("SUPER_ADMIN")) return true; + return (ROLE_PERMISSIONS[permission] ?? []).some((role) => me.roles.includes(role)); + }; +} + +// Which roles grant each permission the portal gates on (subset of the server +// catalog in backend/functions/src/middleware/rbac.ts). +const ROLE_PERMISSIONS: Record = { + "employees:read": ["HR_ADMIN", "PAYROLL_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "employees:write": ["HR_ADMIN"], + "attendance:read": ["HR_ADMIN", "PAYROLL_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "leave:approve": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], +}; diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx new file mode 100644 index 0000000..dd48560 --- /dev/null +++ b/web/src/auth/LoginPage.tsx @@ -0,0 +1,87 @@ +import { useState, type FormEvent } from "react"; +import { FirebaseError } from "firebase/app"; +import { NoManagerAccessError, useAuth } from "./AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { LOCALES } from "../i18n/strings"; + +export function LoginPage() { + const { signIn } = useAuth(); + const { t, locale, setLocale } = useI18n(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + await signIn(email, password); + } catch (err) { + if (err instanceof NoManagerAccessError) { + setError(t("login_no_access")); + } else if (err instanceof FirebaseError) { + setError(t("login_error")); + } else { + setError(t("common_error")); + } + } finally { + setBusy(false); + } + } + + return ( +
+
+
WorkTrack
+
{t("tagline")}
+ +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ + {error &&
{error}
} + + + +
+ {LOCALES.map((l) => ( + + ))} +
+ +
+ ); +} diff --git a/web/src/firebase.ts b/web/src/firebase.ts new file mode 100644 index 0000000..af7a996 --- /dev/null +++ b/web/src/firebase.ts @@ -0,0 +1,13 @@ +import { initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; + +// Public web config — safe to ship in the client bundle. Access control is +// enforced by the API (bearer token + RBAC), not by hiding these values. +const firebaseApp = initializeApp({ + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, +}); + +export const auth = getAuth(firebaseApp); diff --git a/web/src/i18n/LocaleProvider.tsx b/web/src/i18n/LocaleProvider.tsx new file mode 100644 index 0000000..014bde9 --- /dev/null +++ b/web/src/i18n/LocaleProvider.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { DICTIONARIES, LOCALES, type Locale } from "./strings"; +import { toShamsi, type ShamsiDate } from "../shamsi/solarHijri"; + +const SHAMSI_MONTHS: Record = { + fa: ["حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت"], + ps: ["وری", "غویی", "غبرګولی", "چنګاښ", "زمری", "وږی", "تله", "لړم", "لیندۍ", "مرغومی", "سلواغه", "کب"], + en: ["Hamal", "Sawr", "Jawza", "Saratan", "Asad", "Sunbula", "Mizan", "Aqrab", "Qaws", "Jadi", "Dalw", "Hut"], +}; + +const EASTERN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +interface LocaleContextValue { + locale: Locale; + dir: "rtl" | "ltr"; + setLocale: (locale: Locale) => void; + /** Translate a key, with optional {0},{1} interpolation. */ + t: (key: string, ...args: (string | number)[]) => string; + /** Localize Latin digits to ۰–۹ for fa/ps. */ + num: (value: string | number) => string; + /** Format an ISO date as a Solar Hijri string, e.g. "۲۶ سرطان ۱۴۰۵". */ + shamsi: (isoDate: string, opts?: { withYear?: boolean }) => string; + shamsiMonthName: (month: number) => string; +} + +const LocaleContext = createContext(null); + +const STORAGE_KEY = "worktrack.locale"; + +export function LocaleProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) as Locale | null; + return stored && LOCALES.some((l) => l.code === stored) ? stored : "fa"; + }); + + const dir = LOCALES.find((l) => l.code === locale)?.dir ?? "rtl"; + + useEffect(() => { + document.documentElement.lang = locale; + document.documentElement.dir = dir; + }, [locale, dir]); + + const setLocale = useCallback((next: Locale) => { + localStorage.setItem(STORAGE_KEY, next); + setLocaleState(next); + }, []); + + const num = useCallback( + (value: string | number): string => { + const s = String(value); + if (locale === "en") return s; + return s.replace(/[0-9]/g, (d) => EASTERN_DIGITS[Number(d)]); + }, + [locale], + ); + + const shamsiMonthName = useCallback( + (month: number): string => SHAMSI_MONTHS[locale][Math.min(Math.max(month - 1, 0), 11)], + [locale], + ); + + const value = useMemo(() => { + const dict = DICTIONARIES[locale]; + const t = (key: string, ...args: (string | number)[]): string => { + let text = dict[key] ?? key; + args.forEach((arg, i) => { + text = text.replace(`{${i}}`, String(arg)); + }); + return text; + }; + const shamsi = (isoDate: string, opts?: { withYear?: boolean }): string => { + const d: ShamsiDate = toShamsi(isoDate); + const base = `${d.day} ${SHAMSI_MONTHS[locale][d.month - 1]}${ + opts?.withYear ? ` ${d.year}` : "" + }`; + return locale === "en" ? base : base.replace(/[0-9]/g, (n) => EASTERN_DIGITS[Number(n)]); + }; + return { locale, dir, setLocale, t, num, shamsi, shamsiMonthName }; + }, [locale, dir, setLocale, num, shamsiMonthName]); + + return {children}; +} + +export function useI18n(): LocaleContextValue { + const ctx = useContext(LocaleContext); + if (!ctx) throw new Error("useI18n must be used within LocaleProvider"); + return ctx; +} diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts new file mode 100644 index 0000000..f67f7b7 --- /dev/null +++ b/web/src/i18n/strings.ts @@ -0,0 +1,288 @@ +// Trilingual UI strings for the manager portal. Dari is the default; Pashto and +// English are full alternates. Keys mirror the Android app's resource names +// where practical. Interpolation uses {0}, {1} placeholders. + +export type Locale = "fa" | "ps" | "en"; + +export const LOCALES: { code: Locale; label: string; dir: "rtl" | "ltr" }[] = [ + { code: "fa", label: "دری", dir: "rtl" }, + { code: "ps", label: "پښتو", dir: "rtl" }, + { code: "en", label: "English", dir: "ltr" }, +]; + +type Dict = Record; + +const fa: Dict = { + app_title: "پورتال مدیر WorkTrack", + tagline: "مدیریت هوشمند نیروی کار برای افغانستان", + + nav_dashboard: "داشبورد", + nav_employees: "کارمندان", + nav_attendance: "حاضری", + nav_leave: "رخصتی‌ها", + nav_logout: "خروج", + + login_email: "ایمیل کاری", + login_password: "رمز عبور", + login_submit: "ورود", + login_error: "ایمیل یا رمز عبور نادرست است", + login_no_access: "این حساب دسترسی مدیریتی ندارد", + login_signing_in: "در حال ورود…", + + dash_title: "نمای کلی امروز", + dash_active_employees: "کارمندان فعال", + dash_present: "حاضر", + dash_absent: "غیرحاضر", + dash_on_leave: "در رخصتی", + dash_late: "ناوقت", + dash_half_day: "نیم روز", + dash_pending_leave: "رخصتی‌های در انتظار", + dash_attendance_rate: "نرخ حاضری", + dash_trend: "روند حاضری (۷ روز)", + + emp_title: "کارمندان", + emp_add: "افزودن کارمند", + emp_code: "کود", + emp_name: "نام", + emp_email: "ایمیل", + emp_branch: "شعبه", + emp_status: "وضعیت", + emp_type: "نوع استخدام", + emp_join_date: "تاریخ شمولیت", + emp_phone: "تلیفون", + emp_save: "ذخیره", + emp_cancel: "لغو", + emp_created: "کارمند اضافه شد", + emp_search: "جستجوی نام یا کود…", + emp_load_more: "بارگذاری بیشتر", + emp_empty: "کارمندی یافت نشد", + + status_active: "فعال", + status_on_leave: "در رخصتی", + status_suspended: "معلق", + status_exited: "خارج شده", + + type_full_time: "تمام‌وقت", + type_part_time: "نیمه‌وقت", + type_contract: "قراردادی", + type_intern: "کارآموز", + + att_title: "مانیتورینگ حاضری", + att_date: "تاریخ", + att_present: "حاضر", + att_absent: "غیرحاضر", + att_first_in: "اولین ورود", + att_worked: "کارکرد", + att_late_by: "{0} دقیقه ناوقت", + att_empty: "برای این روز معلوماتی نیست", + + leave_title: "تاییدی رخصتی", + leave_employee: "کارمند", + leave_dates: "تاریخ‌ها", + leave_days: "روزها", + leave_reason: "دلیل", + leave_approve: "تایید", + leave_reject: "رد", + leave_empty: "درخواست رخصتی در انتظار نیست", + leave_approved: "درخواست تایید شد", + leave_rejected: "درخواست رد شد", + leave_reject_prompt: "دلیل رد را بنویسید:", + + att_status_present: "حاضر", + att_status_absent: "غیرحاضر", + att_status_half_day: "نیم روز", + att_status_leave: "رخصتی", + att_status_holiday: "رخصتی عمومی", + att_status_week_off: "رخصتی هفته", + att_status_pending: "در انتظار", + + common_retry: "تلاش دوباره", + common_loading: "در حال بارگذاری…", + common_error: "مشکلی پیش آمد", + common_days: "روز", + common_minutes: "دقیقه", +}; + +const ps: Dict = { + app_title: "د WorkTrack مدیر پورتال", + tagline: "د افغانستان لپاره د کاري ځواک هوښیار مدیریت", + + nav_dashboard: "ډشبورډ", + nav_employees: "کارکوونکي", + nav_attendance: "حاضري", + nav_leave: "رخصتۍ", + nav_logout: "وتل", + + login_email: "کاري برېښنالیک", + login_password: "پټنوم", + login_submit: "ننوتل", + login_error: "برېښنالیک یا پټنوم سم نه دی", + login_no_access: "دا حساب مدیریتي لاسرسی نه لري", + login_signing_in: "ننوتل کېږي…", + + dash_title: "د نن ورځې لنډیز", + dash_active_employees: "فعال کارکوونکي", + dash_present: "حاضر", + dash_absent: "غیرحاضر", + dash_on_leave: "په رخصتۍ کې", + dash_late: "ناوخته", + dash_half_day: "نیمه ورځ", + dash_pending_leave: "په تمه رخصتۍ", + dash_attendance_rate: "د حاضرۍ کچه", + dash_trend: "د حاضرۍ روند (۷ ورځې)", + + emp_title: "کارکوونکي", + emp_add: "کارکوونکی ورزیاتول", + emp_code: "کوډ", + emp_name: "نوم", + emp_email: "برېښنالیک", + emp_branch: "څانګه", + emp_status: "حالت", + emp_type: "د دندې ډول", + emp_join_date: "د شاملېدو نېټه", + emp_phone: "تلیفون", + emp_save: "خوندي کول", + emp_cancel: "لغوه", + emp_created: "کارکوونکی ورزیات شو", + emp_search: "د نوم یا کوډ لټون…", + emp_load_more: "نور بار کړئ", + emp_empty: "کارکوونکی ونه موندل شو", + + status_active: "فعال", + status_on_leave: "په رخصتۍ کې", + status_suspended: "معطل", + status_exited: "وتلی", + + type_full_time: "بشپړ وخت", + type_part_time: "نیم وخت", + type_contract: "قراردادي", + type_intern: "کارآموز", + + att_title: "د حاضرۍ څارنه", + att_date: "نېټه", + att_present: "حاضر", + att_absent: "غیرحاضر", + att_first_in: "لومړی ورتګ", + att_worked: "کار", + att_late_by: "{0} دقیقې ناوخته", + att_empty: "د دې ورځې لپاره معلومات نشته", + + leave_title: "د رخصتۍ تایید", + leave_employee: "کارکوونکی", + leave_dates: "نېټې", + leave_days: "ورځې", + leave_reason: "دلیل", + leave_approve: "تایید", + leave_reject: "رد", + leave_empty: "په تمه د رخصتۍ غوښتنه نشته", + leave_approved: "غوښتنه تایید شوه", + leave_rejected: "غوښتنه رد شوه", + leave_reject_prompt: "د رد دلیل ولیکئ:", + + att_status_present: "حاضر", + att_status_absent: "غیرحاضر", + att_status_half_day: "نیمه ورځ", + att_status_leave: "رخصتي", + att_status_holiday: "عمومي رخصتي", + att_status_week_off: "اونیزه رخصتي", + att_status_pending: "په تمه", + + common_retry: "بیا هڅه", + common_loading: "بارېږي…", + common_error: "ستونزه رامنځته شوه", + common_days: "ورځې", + common_minutes: "دقیقې", +}; + +const en: Dict = { + app_title: "WorkTrack Manager Portal", + tagline: "Smart workforce management for Afghanistan", + + nav_dashboard: "Dashboard", + nav_employees: "Employees", + nav_attendance: "Attendance", + nav_leave: "Leave", + nav_logout: "Sign out", + + login_email: "Work email", + login_password: "Password", + login_submit: "Sign in", + login_error: "Email or password is incorrect", + login_no_access: "This account has no manager access", + login_signing_in: "Signing in…", + + dash_title: "Today at a glance", + dash_active_employees: "Active employees", + dash_present: "Present", + dash_absent: "Absent", + dash_on_leave: "On leave", + dash_late: "Late", + dash_half_day: "Half day", + dash_pending_leave: "Pending leave", + dash_attendance_rate: "Attendance rate", + dash_trend: "Attendance trend (7 days)", + + emp_title: "Employees", + emp_add: "Add employee", + emp_code: "Code", + emp_name: "Name", + emp_email: "Email", + emp_branch: "Branch", + emp_status: "Status", + emp_type: "Employment", + emp_join_date: "Join date", + emp_phone: "Phone", + emp_save: "Save", + emp_cancel: "Cancel", + emp_created: "Employee added", + emp_search: "Search name or code…", + emp_load_more: "Load more", + emp_empty: "No employees found", + + status_active: "Active", + status_on_leave: "On leave", + status_suspended: "Suspended", + status_exited: "Exited", + + type_full_time: "Full time", + type_part_time: "Part time", + type_contract: "Contract", + type_intern: "Intern", + + att_title: "Attendance monitoring", + att_date: "Date", + att_present: "Present", + att_absent: "Absent", + att_first_in: "First in", + att_worked: "Worked", + att_late_by: "Late by {0} min", + att_empty: "No data for this day", + + leave_title: "Leave approvals", + leave_employee: "Employee", + leave_dates: "Dates", + leave_days: "Days", + leave_reason: "Reason", + leave_approve: "Approve", + leave_reject: "Reject", + leave_empty: "No leave requests pending", + leave_approved: "Request approved", + leave_rejected: "Request rejected", + leave_reject_prompt: "Enter the rejection reason:", + + att_status_present: "Present", + att_status_absent: "Absent", + att_status_half_day: "Half day", + att_status_leave: "Leave", + att_status_holiday: "Public holiday", + att_status_week_off: "Week off", + att_status_pending: "Pending", + + common_retry: "Retry", + common_loading: "Loading…", + common_error: "Something went wrong", + common_days: "days", + common_minutes: "min", +}; + +export const DICTIONARIES: Record = { fa, ps, en }; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..9ee6c6f --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,28 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import { LocaleProvider } from "./i18n/LocaleProvider"; +import { AuthProvider } from "./auth/AuthProvider"; +import { App } from "./App"; +import "./styles.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 }, + }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + + + + + , +); diff --git a/web/src/pages/AttendancePage.tsx b/web/src/pages/AttendancePage.tsx new file mode 100644 index 0000000..6951953 --- /dev/null +++ b/web/src/pages/AttendancePage.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from "react"; +import { useAttendanceOverview } from "../api/hooks"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, StatusChip } from "../ui/components"; + +export function AttendancePage() { + const { t, num, shamsi } = useI18n(); + const [date, setDate] = useState(isoToday()); + const overview = useAttendanceOverview(date); + + const summary = useMemo(() => { + const rows = overview.data ?? []; + const present = rows.filter((r) => r.status === "PRESENT" || r.status === "HALF_DAY").length; + return { present, absent: rows.length - present, total: rows.length }; + }, [overview.data]); + + return ( + <> +
+

{t("att_title")}

+
+ {shamsi(date, { withYear: true })} + setDate(e.target.value)} + /> +
+
+ + {overview.isLoading ? ( + + ) : overview.isError ? ( + void overview.refetch()} /> + ) : (overview.data?.length ?? 0) === 0 ? ( + + ) : ( + <> +
+
+
{num(summary.present)}
+
{t("att_present")}
+
+
+
{num(summary.absent)}
+
{t("att_absent")}
+
+
+ +
+
+ + + + + + + + + + {overview.data!.map((r) => ( + + + + + + + ))} + +
{t("leave_employee")}{t("emp_status")}{t("att_first_in")}{t("att_worked")}
{r.employeeName} + + {r.lateMinutes > 0 && ( + + {t("att_late_by", num(r.lateMinutes))} + + )} + {r.firstInAt ? formatTime(r.firstInAt, num) : "—"} + {r.workedMinutes > 0 + ? `${num(Math.floor(r.workedMinutes / 60))}:${num( + String(r.workedMinutes % 60).padStart(2, "0"), + )}` + : "—"} +
+ + + )} + + ); +} + +function formatTime(iso: string, num: (v: string | number) => string): string { + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return num(`${hh}:${mm}`); +} + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..7d71e2f --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -0,0 +1,92 @@ +import { useAttendanceTrend, useKpis } from "../api/hooks"; +import { useI18n } from "../i18n/LocaleProvider"; +import { ErrorState, LoadingState } from "../ui/components"; +import { toShamsi } from "../shamsi/solarHijri"; + +export function DashboardPage() { + const { t, num, shamsi } = useI18n(); + const kpis = useKpis(); + const trend = useAttendanceTrend(); + + if (kpis.isLoading) return ; + if (kpis.isError || !kpis.data) { + return void kpis.refetch()} />; + } + const k = kpis.data; + + return ( + <> +
+

{t("dash_title")}

+ {shamsi(k.date, { withYear: true })} +
+ +
+ + + + + + + + +
+ +
+

{t("dash_trend")}

+ {trend.data && trend.data.length > 0 ? ( + ({ + present: p.present, + cap: shamsi(p.date), + isToday: p.date === k.date, + }))} + /> + ) : ( +
{t("common_loading")}
+ )} +
+ + {/* Keep an eye on the "as of" note reading the same Shamsi calendar. */} +

+ {t("att_date")}: {num(toShamsi(k.date).day)} {shamsi(k.date, { withYear: true })} +

+ + ); +} + +function Kpi({ + value, + label, + accent, +}: { + value: string; + label: string; + accent?: "red" | "amber"; +}) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Trend({ points }: { points: { present: number; cap: string; isToday: boolean }[] }) { + const max = Math.max(1, ...points.map((p) => p.present)); + const { num } = useI18n(); + return ( +
+ {points.map((p, i) => ( +
+ {num(p.present)} +
+ {p.cap} +
+ ))} +
+ ); +} diff --git a/web/src/pages/EmployeesPage.tsx b/web/src/pages/EmployeesPage.tsx new file mode 100644 index 0000000..5314076 --- /dev/null +++ b/web/src/pages/EmployeesPage.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { useCreateEmployee, useEmployees } from "../api/hooks"; +import { ApiError } from "../api/client"; +import type { Employee, EmployeeStatus, EmploymentType } from "../api/types"; +import { useAuth, useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; + +const EMPLOYMENT_TYPES: EmploymentType[] = ["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]; + +export function EmployeesPage() { + const { t, num, shamsi } = useI18n(); + const can = useHasPermission(); + const [search, setSearch] = useState(""); + const [showForm, setShowForm] = useState(false); + const [toast, setToast] = useState(null); + + const employees = useEmployees({}); + + const filtered = useMemo(() => { + const rows = employees.data?.data ?? []; + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (e) => + `${e.firstName} ${e.lastName}`.toLowerCase().includes(q) || + e.employeeCode.toLowerCase().includes(q) || + e.email.toLowerCase().includes(q), + ); + }, [employees.data, search]); + + return ( + <> +
+

{t("emp_title")}

+ {can("employees:write") && ( + + )} +
+ +
+ setSearch(e.target.value)} + /> +
+ + {employees.isLoading ? ( + + ) : employees.isError ? ( + void employees.refetch()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {filtered.map((e: Employee) => ( + + + + + + + + + ))} + +
{t("emp_code")}{t("emp_name")}{t("emp_email")}{t("emp_type")}{t("emp_join_date")}{t("emp_status")}
{num(e.employeeCode)} + {e.firstName} {e.lastName} + {e.email}{t(`type_${e.employmentType.toLowerCase()}`)}{shamsi(e.joinDate, { withYear: true })} + +
+
+ )} + + {showForm && ( + setShowForm(false)} + onCreated={() => { + setShowForm(false); + setToast(t("emp_created")); + window.setTimeout(() => setToast(null), 2500); + }} + /> + )} + {toast && } + + ); +} + +function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const { t } = useI18n(); + const { me } = useAuth(); + const create = useCreateEmployee(); + const [fieldErrors, setFieldErrors] = useState>({}); + const [form, setForm] = useState({ + employeeCode: "", + firstName: "", + lastName: "", + email: "", + phone: "", + branchId: me?.branchIds[0] ?? "", + employmentType: "FULL_TIME" as EmploymentType, + joinDate: isoToday(), + status: "ACTIVE" as EmployeeStatus, + }); + + function set(key: K, value: (typeof form)[K]) { + setForm((f) => ({ ...f, [key]: value })); + } + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setFieldErrors({}); + try { + await create.mutateAsync({ + employeeCode: form.employeeCode, + firstName: form.firstName, + lastName: form.lastName, + email: form.email, + phone: form.phone || null, + branchId: form.branchId || null, + employmentType: form.employmentType, + joinDate: form.joinDate, + status: form.status, + }); + onCreated(); + } catch (err) { + if (err instanceof ApiError) setFieldErrors(err.fieldErrors); + } + } + + return ( +
+
e.stopPropagation()} onSubmit={onSubmit}> +

{t("emp_add")}

+
+ set("employeeCode", v)} error={fieldErrors.employeeCode} /> + set("phone", v)} dir="ltr" /> + set("firstName", v)} error={fieldErrors.firstName} /> + set("lastName", v)} error={fieldErrors.lastName} /> +
+ set("email", v)} dir="ltr" error={fieldErrors.email} /> +
+
+ + +
+
+ + set("joinDate", e.target.value)} /> +
+
+ + {create.isError && !Object.keys(fieldErrors).length && ( +
{t("common_error")}
+ )} + +
+ + +
+ +
+ ); +} + +function Text({ + label, + value, + onChange, + error, + dir, +}: { + label: string; + value: string; + onChange: (v: string) => void; + error?: string; + dir?: "ltr" | "rtl"; +}) { + return ( +
+ + onChange(e.target.value)} /> + {error && {error}} +
+ ); +} + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} diff --git a/web/src/pages/LeavePage.tsx b/web/src/pages/LeavePage.tsx new file mode 100644 index 0000000..dcb26e7 --- /dev/null +++ b/web/src/pages/LeavePage.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { useDecideLeave, usePendingApprovals } from "../api/hooks"; +import type { LeaveRequest } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +export function LeavePage() { + const { t, num, shamsi } = useI18n(); + const approvals = usePendingApprovals(); + const decide = useDecideLeave(); + const [toast, setToast] = useState(null); + const [busyId, setBusyId] = useState(null); + + function flash(message: string) { + setToast(message); + window.setTimeout(() => setToast(null), 2500); + } + + async function onDecide(req: LeaveRequest, decision: "APPROVE" | "REJECT") { + let note: string | null = null; + if (decision === "REJECT") { + note = window.prompt(t("leave_reject_prompt")) ?? ""; + if (!note.trim()) return; // rejection requires a note (server enforces too) + } + setBusyId(req.id); + try { + await decide.mutateAsync({ id: req.id, decision, note }); + flash(decision === "APPROVE" ? t("leave_approved") : t("leave_rejected")); + } catch { + flash(t("common_error")); + } finally { + setBusyId(null); + } + } + + return ( + <> +
+

{t("leave_title")}

+
+ + {approvals.isLoading ? ( + + ) : approvals.isError ? ( + void approvals.refetch()} /> + ) : (approvals.data?.length ?? 0) === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {approvals.data!.map((req) => ( + + + + + + + + ))} + +
{t("leave_employee")}{t("leave_dates")}{t("leave_days")}{t("leave_reason")} +
{req.employeeName ?? req.employeeId} + {shamsi(req.startDate)} – {shamsi(req.endDate, { withYear: true })} + + {num(req.days)} {t("common_days")} + {req.reason} +
+ + +
+
+
+ )} + {toast && } + + ); +} diff --git a/web/src/shamsi/solarHijri.ts b/web/src/shamsi/solarHijri.ts new file mode 100644 index 0000000..5179857 --- /dev/null +++ b/web/src/shamsi/solarHijri.ts @@ -0,0 +1,125 @@ +// Solar Hijri (هجری شمسی) <-> Gregorian conversion — the TypeScript port of the +// Android app's core:common/time/SolarHijri.kt (jalaali break-year algorithm). +// The business calendar of WorkTrack is Solar Hijri; storage/API stay ISO. + +export interface ShamsiDate { + year: number; + month: number; // 1..12, 1 = Hamal/حمل + day: number; +} + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, + 2262, 2324, 2394, 2456, 3178, +]; + +interface JalCal { + leap: number; + gy: number; + march: number; +} + +function jalCal(jy: number): JalCal { + const gy = jy + 621; + let leapJ = -14; + let jp = BREAKS[0]; + + let jump = 0; + for (let i = 1; i < BREAKS.length; i++) { + const jm = BREAKS[i]; + jump = jm - jp; + if (jy < jm) break; + leapJ += Math.floor(jump / 33) * 8 + Math.floor((jump % 33) / 4); + jp = jm; + } + let n = jy - jp; + + leapJ += Math.floor(n / 33) * 8 + Math.floor(((n % 33) + 3) / 4); + if (jump % 33 === 4 && jump - n === 4) leapJ += 1; + + const leapG = Math.floor(gy / 4) - Math.floor((Math.floor(gy / 100) + 1) * 3 / 4) - 150; + const march = 20 + leapJ - leapG; + + if (jump - n < 6) n = n - jump + Math.floor((jump + 4) / 33) * 33; + let leap = (((n + 1) % 33) - 1) % 4; + if (leap === -1) leap = 4; + + return { leap, gy, march }; +} + +function g2d(gy: number, gm: number, gd: number): number { + let d = + Math.floor((gy + Math.floor((gm - 8) / 6) + 100100) * 1461 / 4) + + Math.floor((153 * ((gm + 9) % 12) + 2) / 5) + + gd - + 34840408; + d = d - Math.floor((Math.floor((gy + 100100 + Math.floor((gm - 8) / 6)) / 100) * 3) / 4) + 752; + return d; +} + +function d2g(jdn: number): { gy: number; gm: number; gd: number } { + let j = 4 * jdn + 139361631; + j += Math.floor((Math.floor((4 * jdn + 183187720) / 146097) * 3) / 4) * 4 - 3908; + const i = Math.floor((j % 1461) / 4) * 5 + 308; + const gd = Math.floor((i % 153) / 5) + 1; + const gm = (Math.floor(i / 153) % 12) + 1; + const gy = Math.floor(j / 1461) - 100100 + Math.floor((8 - gm) / 6); + return { gy, gm, gd }; +} + +function j2d(jy: number, jm: number, jd: number): number { + const r = jalCal(jy); + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - Math.floor(jm / 7) * (jm - 7) + jd - 1; +} + +function d2j(jdn: number): ShamsiDate { + const gy = d2g(jdn).gy; + let jy = gy - 621; + const r = jalCal(jy); + const jdn1f = g2d(gy, 3, r.march); + let k = jdn - jdn1f; + + if (k >= 0) { + if (k <= 185) { + return { year: jy, month: 1 + Math.floor(k / 31), day: (k % 31) + 1 }; + } + k -= 186; + } else { + jy -= 1; + k += 179; + if (r.leap === 1) k += 1; + } + return { year: jy, month: 7 + Math.floor(k / 30), day: (k % 30) + 1 }; +} + +/** Parses an ISO date (YYYY-MM-DD) into its Solar Hijri equivalent. */ +export function toShamsi(isoDate: string): ShamsiDate { + const [y, m, d] = isoDate.split("-").map((v) => Number.parseInt(v, 10)); + return d2j(g2d(y, m, d)); +} + +export function toShamsiFromDate(date: Date): ShamsiDate { + return d2j(g2d(date.getFullYear(), date.getMonth() + 1, date.getDate())); +} + +/** Solar Hijri date -> ISO date string (YYYY-MM-DD). */ +export function shamsiToIso(date: ShamsiDate): string { + const g = d2g(j2d(date.year, date.month, date.day)); + return `${g.gy.toString().padStart(4, "0")}-${g.gm + .toString() + .padStart(2, "0")}-${g.gd.toString().padStart(2, "0")}`; +} + +export function isShamsiLeapYear(year: number): boolean { + return jalCal(year).leap === 0; +} + +export function shamsiMonthLength(year: number, month: number): number { + if (month <= 6) return 31; + if (month <= 11) return 30; + return isShamsiLeapYear(year) ? 30 : 29; +} + +export function shamsiToday(): ShamsiDate { + return toShamsiFromDate(new Date()); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..7e6b11e --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,489 @@ +/* WorkTrack manager portal — design system. RTL-first via CSS logical + properties (margin-inline, inset-inline) so Dari/Pashto and English share + one stylesheet. Teal brand matches the Android app. */ + +:root { + --teal-40: #006874; + --teal-30: #004f58; + --teal-90: #97f0ff; + --teal-container: #cfeef2; + --slate-10: #0f1417; + --slate-30: #3a4043; + --slate-50: #6b7378; + --slate-90: #dee3e6; + --slate-95: #eef2f4; + --slate-99: #fbfdfe; + --surface: #ffffff; + --bg: #f4f7f8; + --outline: #d3dade; + + --green: #2e7d32; + --green-bg: #d7efd8; + --amber: #8a6100; + --amber-bg: #ffe8b3; + --red: #b3261e; + --red-bg: #f9dedc; + --neutral: #49545a; + --neutral-bg: #e1e8ed; + + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 1px 3px rgba(15, 20, 23, 0.12), 0 1px 2px rgba(15, 20, 23, 0.06); + --sidebar-width: 244px; + + --font: "Vazirmatn", "Segoe UI", "Noto Naskh Arabic", system-ui, -apple-system, + sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--slate-10); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: none; + border-radius: 999px; + padding: 10px 22px; + font-size: 14px; + font-weight: 600; + transition: filter 0.15s ease, background 0.15s ease; +} +.btn:disabled { + opacity: 0.55; + cursor: default; +} +.btn-primary { + background: var(--teal-40); + color: #fff; +} +.btn-primary:not(:disabled):hover { + filter: brightness(1.08); +} +.btn-outline { + background: transparent; + color: var(--teal-40); + border: 1px solid var(--outline); +} +.btn-outline:not(:disabled):hover { + background: var(--slate-95); +} +.btn-danger { + background: var(--red-bg); + color: var(--red); +} +.btn-sm { + padding: 6px 14px; + font-size: 13px; +} + +/* ---------- Inputs ---------- */ +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-block-end: 16px; +} +.field label { + font-size: 13px; + font-weight: 600; + color: var(--slate-30); +} +.input, +.select { + width: 100%; + padding: 11px 14px; + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + font-size: 15px; + font-family: inherit; + background: var(--surface); + color: var(--slate-10); +} +.input:focus, +.select:focus { + outline: 2px solid var(--teal-40); + outline-offset: -1px; +} +.field-error { + color: var(--red); + font-size: 13px; +} + +/* ---------- Card ---------- */ +.card { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 20px; +} + +/* ---------- Chips ---------- */ +.chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-sm); + padding: 3px 10px; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} +.chip-positive { + background: var(--green-bg); + color: var(--green); +} +.chip-warning { + background: var(--amber-bg); + color: var(--amber); +} +.chip-negative { + background: var(--red-bg); + color: var(--red); +} +.chip-neutral { + background: var(--neutral-bg); + color: var(--neutral); +} + +/* ---------- Layout shell ---------- */ +.shell { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100%; +} +.sidebar { + background: var(--surface); + border-inline-end: 1px solid var(--outline); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 4px; +} +.brand { + font-size: 22px; + font-weight: 800; + color: var(--teal-40); + padding: 8px 12px 4px; +} +.brand small { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--slate-50); +} +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + border-radius: var(--radius-sm); + color: var(--slate-30); + font-weight: 600; + font-size: 14.5px; +} +.nav-item:hover { + background: var(--slate-95); +} +.nav-item.active { + background: var(--teal-container); + color: var(--teal-30); +} +.nav-item .icon { + width: 20px; + text-align: center; +} +.sidebar-spacer { + flex: 1; +} +.main { + padding: 24px 28px; + overflow: auto; +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-block-end: 20px; + gap: 16px; +} +.page-title { + font-size: 22px; + font-weight: 700; + margin: 0; +} +.topbar-right { + display: flex; + align-items: center; + gap: 12px; +} +.lang-switch { + display: inline-flex; + border: 1px solid var(--outline); + border-radius: 999px; + overflow: hidden; +} +.lang-switch button { + border: none; + background: transparent; + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + color: var(--slate-50); +} +.lang-switch button.active { + background: var(--teal-40); + color: #fff; +} +.user-chip { + font-size: 13px; + color: var(--slate-50); +} + +/* ---------- KPI grid ---------- */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 14px; + margin-block-end: 22px; +} +.kpi { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px 18px; +} +.kpi .value { + font-size: 30px; + font-weight: 800; + line-height: 1.1; + color: var(--teal-40); +} +.kpi .label { + font-size: 13px; + color: var(--slate-50); + margin-block-start: 4px; +} +.kpi.accent-red .value { + color: var(--red); +} +.kpi.accent-amber .value { + color: var(--amber); +} + +/* ---------- Table ---------- */ +.table-wrap { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: auto; +} +table.data { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +table.data th, +table.data td { + text-align: start; + padding: 12px 16px; + border-block-end: 1px solid var(--slate-95); + white-space: nowrap; +} +table.data th { + font-size: 12.5px; + color: var(--slate-50); + font-weight: 600; + background: var(--slate-99); + position: sticky; + top: 0; +} +table.data tbody tr:hover { + background: var(--slate-99); +} +.row-actions { + display: flex; + gap: 8px; +} + +/* ---------- States ---------- */ +.center-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--slate-50); + text-align: center; +} +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--slate-90); + border-top-color: var(--teal-40); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ---------- Login ---------- */ +.login-page { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.login-card { + width: 100%; + max-width: 380px; + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 32px; + text-align: center; +} +.login-card .brand { + padding: 0; + margin-block-end: 4px; +} +.login-card .tagline { + color: var(--slate-50); + font-size: 14px; + margin-block-end: 24px; +} +.login-card .field { + text-align: start; +} + +/* ---------- Modal ---------- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 20, 23, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 50; +} +.modal { + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow: auto; + background: var(--surface); + border-radius: var(--radius); + padding: 24px; +} +.modal h2 { + margin: 0 0 16px; + font-size: 18px; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 16px; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-block-start: 12px; +} +.toast { + position: fixed; + inset-block-end: 24px; + inset-inline-start: 50%; + transform: translateX(-50%); + background: var(--slate-10); + color: #fff; + padding: 12px 20px; + border-radius: 999px; + font-size: 14px; + z-index: 60; +} + +/* ---------- Bars (trend) ---------- */ +.trend { + display: flex; + align-items: flex-end; + gap: 10px; + height: 120px; + padding-block-start: 8px; +} +.trend-bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + height: 100%; + justify-content: flex-end; +} +.trend-bar .bar { + width: 100%; + max-width: 40px; + background: var(--teal-container); + border-radius: 6px 6px 0 0; + min-height: 3px; +} +.trend-bar .bar.today { + background: var(--teal-40); +} +.trend-bar .cap { + font-size: 11px; + color: var(--slate-50); +} + +@media (max-width: 760px) { + .shell { + grid-template-columns: 1fr; + } + .sidebar { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-inline-end: none; + border-block-end: 1px solid var(--outline); + } + .sidebar-spacer { + display: none; + } + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/web/src/ui/Layout.tsx b/web/src/ui/Layout.tsx new file mode 100644 index 0000000..50c6ca1 --- /dev/null +++ b/web/src/ui/Layout.tsx @@ -0,0 +1,68 @@ +import { NavLink, Outlet } from "react-router-dom"; +import { useAuth, useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { LOCALES } from "../i18n/strings"; + +export function Layout() { + const { me, signOut } = useAuth(); + const { t, locale, setLocale } = useI18n(); + const can = useHasPermission(); + + const navItems = [ + { to: "/", icon: "▤", label: t("nav_dashboard"), show: true, end: true }, + { to: "/employees", icon: "◍", label: t("nav_employees"), show: can("employees:read") }, + { to: "/attendance", icon: "◷", label: t("nav_attendance"), show: can("attendance:read") }, + { to: "/leave", icon: "✈", label: t("nav_leave"), show: can("leave:approve") }, + ]; + + return ( +
+ + +
+
+
+ {LOCALES.map((l) => ( + + ))} +
+
+ + {me?.displayName} · {me?.companyName} + +
+
+ +
+
+ ); +} diff --git a/web/src/ui/components.tsx b/web/src/ui/components.tsx new file mode 100644 index 0000000..f5449d2 --- /dev/null +++ b/web/src/ui/components.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { useI18n } from "../i18n/LocaleProvider"; + +export function Spinner() { + return
; +} + +export function LoadingState() { + const { t } = useI18n(); + return ( +
+ + {t("common_loading")} +
+ ); +} + +export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { + const { t } = useI18n(); + return ( +
+ {message} + {onRetry && ( + + )} +
+ ); +} + +export function EmptyState({ message }: { message: string }) { + return
{message}
; +} + +type Tone = "positive" | "warning" | "negative" | "neutral"; + +export function Chip({ tone, children }: { tone: Tone; children: ReactNode }) { + return {children}; +} + +/** Maps an attendance/leave status to a chip tone + localized label. */ +export function StatusChip({ status }: { status: string }) { + const { t } = useI18n(); + const map: Record = { + PRESENT: { tone: "positive", key: "att_status_present" }, + ABSENT: { tone: "negative", key: "att_status_absent" }, + HALF_DAY: { tone: "warning", key: "att_status_half_day" }, + LEAVE: { tone: "neutral", key: "att_status_leave" }, + HOLIDAY: { tone: "neutral", key: "att_status_holiday" }, + WEEK_OFF: { tone: "neutral", key: "att_status_week_off" }, + PENDING: { tone: "warning", key: "att_status_pending" }, + ACTIVE: { tone: "positive", key: "status_active" }, + ON_LEAVE: { tone: "neutral", key: "status_on_leave" }, + SUSPENDED: { tone: "warning", key: "status_suspended" }, + EXITED: { tone: "neutral", key: "status_exited" }, + }; + const entry = map[status] ?? { tone: "neutral" as Tone, key: status }; + return {map[status] ? t(entry.key) : status}; +} + +export function Toast({ message }: { message: string }) { + return
{message}
; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..8eb70ec --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + readonly VITE_FIREBASE_API_KEY: string; + readonly VITE_FIREBASE_AUTH_DOMAIN: string; + readonly VITE_FIREBASE_PROJECT_ID: string; + readonly VITE_FIREBASE_APP_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..98ffac8 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..9c43072 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..936de8b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Manager portal SPA. Built output goes to dist/ and is served by Firebase +// Hosting (see backend/firebase.json hosting config). +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +}); From 2aa84531c479ecd1a8f2a6b2813125a5fb0b1384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:43:58 +0000 Subject: [PATCH 013/139] fix(web): show setup screen instead of white-screening when Firebase unset With an empty .env.local (freshly copied from .env.example), getAuth() was called with an empty apiKey, which throws at module load and leaves a blank white page with no hint why. Guard Firebase init behind a firebaseConfigured flag; when the web config is missing, render a bilingual (Dari/English) SetupNeeded screen listing the exact .env.local keys to fill in, and skip mounting the auth-dependent tree entirely. Also harden the API client against an undefined VITE_API_BASE_URL. tsc + vite build pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- web/src/api/client.ts | 2 +- web/src/firebase.ts | 19 ++++++++--- web/src/main.tsx | 20 +++++++---- web/src/ui/SetupNeeded.tsx | 69 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 web/src/ui/SetupNeeded.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 99d686a..07ab297 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,7 +1,7 @@ import { auth } from "../firebase"; import type { Envelope, Problem } from "./types"; -const BASE_URL = import.meta.env.VITE_API_BASE_URL.replace(/\/$/, ""); +const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/$/, ""); /** Typed API error carrying the RFC 7807 problem code and any field errors. */ export class ApiError extends Error { diff --git a/web/src/firebase.ts b/web/src/firebase.ts index af7a996..55bcdf9 100644 --- a/web/src/firebase.ts +++ b/web/src/firebase.ts @@ -1,13 +1,24 @@ import { initializeApp } from "firebase/app"; -import { getAuth } from "firebase/auth"; +import { getAuth, type Auth } from "firebase/auth"; // Public web config — safe to ship in the client bundle. Access control is // enforced by the API (bearer token + RBAC), not by hiding these values. -const firebaseApp = initializeApp({ +const config = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID, -}); +}; -export const auth = getAuth(firebaseApp); +/** + * True only when the Firebase web config is filled in (.env.local). When false + * the app renders a setup screen instead of initializing Firebase — otherwise + * getAuth() throws on an empty apiKey and the whole page white-screens. + */ +export const firebaseConfigured = Boolean(config.apiKey && config.projectId); + +// A stub is fine when unconfigured: the auth-dependent tree is never mounted +// in that case (see main.tsx), so `auth` is never actually touched. +export const auth: Auth = firebaseConfigured + ? getAuth(initializeApp(config)) + : ({} as Auth); diff --git a/web/src/main.tsx b/web/src/main.tsx index 9ee6c6f..e39f816 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,6 +5,8 @@ import { BrowserRouter } from "react-router-dom"; import { LocaleProvider } from "./i18n/LocaleProvider"; import { AuthProvider } from "./auth/AuthProvider"; import { App } from "./App"; +import { SetupNeeded } from "./ui/SetupNeeded"; +import { firebaseConfigured } from "./firebase"; import "./styles.css"; const queryClient = new QueryClient({ @@ -16,13 +18,17 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - - - - - - - + {firebaseConfigured ? ( + + + + + + + + ) : ( + + )} , ); diff --git a/web/src/ui/SetupNeeded.tsx b/web/src/ui/SetupNeeded.tsx new file mode 100644 index 0000000..949eb84 --- /dev/null +++ b/web/src/ui/SetupNeeded.tsx @@ -0,0 +1,69 @@ +import { useI18n } from "../i18n/LocaleProvider"; +import { LOCALES } from "../i18n/strings"; + +/** + * Shown when the Firebase web config is missing from .env.local. Prevents the + * white-screen you get from initializing Firebase Auth with an empty apiKey, + * and tells the operator exactly what to fill in. + */ +export function SetupNeeded() { + const { locale, setLocale } = useI18n(); + const en = locale === "en"; + + return ( +
+
+
+ WorkTrack +
+

+ {en + ? "Firebase is not configured yet" + : "تنظیمات Firebase هنوز کامل نیست"} +

+

+ {en ? ( + <> + Create web/.env.local and fill in your Firebase web app + config (Firebase console → Project settings → Your apps → Web), then + restart the dev server: + + ) : ( + <> + فایل web/.env.local را بسازید و تنظیمات Firebase خود را + پر کنید (از Firebase console → Project settings → Your apps → Web)، + بعد سرور را دوباره اجرا کنید: + + )} +

+
+{`VITE_FIREBASE_API_KEY=...
+VITE_FIREBASE_AUTH_DOMAIN=...
+VITE_FIREBASE_PROJECT_ID=...
+VITE_FIREBASE_APP_ID=...
+VITE_API_BASE_URL=http://127.0.0.1:5001//us-central1/api/v1`}
+        
+
+ {LOCALES.map((l) => ( + + ))} +
+
+
+ ); +} From d401e8d352ace2fea23e51b24b63bf22b823fdf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:52:01 +0000 Subject: [PATCH 014/139] feat(demo): local emulator seed + one-command demo setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the whole platform locally against the Firebase Emulator Suite with a sample Afghan tenant — no real Firebase project or billing needed. - backend/functions/seed.js + `npm run seed`: seeds the Firestore + Auth emulators with "شرکت ساختمانی کابل" (Kabul), 7 employees, 7 days of varied attendance (present/late/absent/half-day, Friday week-off), 3 pending leave requests routed to the admin, leave types/balances, announcements, AFN salary components, and 3 login users with custom claims (admin@ COMPANY_ADMIN, hr@ HR_ADMIN, ahmad@ EMPLOYEE; pw Passw0rd!) - web: connect to the Auth emulator when VITE_USE_EMULATORS=true; .env.emulator template so `cp .env.emulator .env.local` just works - docs/11-local-demo-setup.md: step-by-step Dari/English guide - gitignore web/.env.local and functions/.secret.local web build + seed syntax check pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- .gitignore | 2 + README.md | 1 + backend/functions/package.json | 1 + backend/functions/seed.js | 363 +++++++++++++++++++++++++++++++++ docs/11-local-demo-setup.md | 127 ++++++++++++ web/.env.emulator | 15 ++ web/src/firebase.ts | 10 +- web/src/vite-env.d.ts | 1 + 8 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 backend/functions/seed.js create mode 100644 docs/11-local-demo-setup.md create mode 100644 web/.env.emulator diff --git a/.gitignore b/.gitignore index 274b97c..0739117 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ captures/ app/google-services.json backend/.firebaserc backend/functions/.env* +backend/functions/.secret.local +web/.env.local *.keystore *.jks diff --git a/README.md b/README.md index 408372d..e849fa9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ calendar — see `docs/10-localization-afghanistan.md`. 9. [Offline-first sync strategy](docs/08-sync-strategy.md) 10. [Development roadmap](docs/09-roadmap.md) 11. [Afghanistan localization (دری/پښتو, Solar Hijri)](docs/10-localization-afghanistan.md) +12. [Local demo setup — run everything with sample data](docs/11-local-demo-setup.md) ## Android app diff --git a/backend/functions/package.json b/backend/functions/package.json index 9ec41e4..45d36d4 100644 --- a/backend/functions/package.json +++ b/backend/functions/package.json @@ -11,6 +11,7 @@ "build": "tsc", "watch": "tsc --watch", "serve": "npm run build && firebase emulators:start --only functions,firestore,auth", + "seed": "node seed.js", "deploy": "firebase deploy --only functions", "typecheck": "tsc --noEmit" }, diff --git a/backend/functions/seed.js b/backend/functions/seed.js new file mode 100644 index 0000000..e6560e5 --- /dev/null +++ b/backend/functions/seed.js @@ -0,0 +1,363 @@ +/* + * Local demo seed for the Firebase Emulator Suite. + * + * Populates the Firestore + Auth emulators with a sample Afghan tenant so the + * web manager portal and the Android app show real data. No real Firebase + * project or billing is required — everything runs locally. + * + * Run (emulators must be started first): + * npm run seed + * + * Logins it creates (password for all: Passw0rd!): + * admin@worktrack.af — COMPANY_ADMIN (use this in the web portal) + * hr@worktrack.af — HR_ADMIN + * ahmad@worktrack.af — EMPLOYEE (use this in the Android app) + */ + +const { initializeApp } = require("firebase-admin/app"); +const { getFirestore, Timestamp } = require("firebase-admin/firestore"); +const { getAuth } = require("firebase-admin/auth"); + +// Point the Admin SDK at the local emulators unless already configured. +process.env.FIRESTORE_EMULATOR_HOST = + process.env.FIRESTORE_EMULATOR_HOST || "127.0.0.1:8080"; +process.env.FIREBASE_AUTH_EMULATOR_HOST = + process.env.FIREBASE_AUTH_EMULATOR_HOST || "127.0.0.1:9099"; + +const PROJECT_ID = process.env.GCLOUD_PROJECT || "demo-worktrack"; +const PASSWORD = "Passw0rd!"; +const CID = "comp_kabul"; + +initializeApp({ projectId: PROJECT_ID }); +const db = getFirestore(); +const auth = getAuth(); + +const now = Timestamp.now(); + +/** companies/{CID}/{collection} */ +function col(collection) { + return db.collection("companies").doc(CID).collection(collection); +} + +/** ISO date (YYYY-MM-DD, UTC) N days before today; 0 = today. */ +function isoDaysAgo(n) { + return new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10); +} + +/** Timestamp at HH:mm UTC on an ISO date. */ +function at(iso, hh, mm) { + return Timestamp.fromDate(new Date(`${iso}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00Z`)); +} + +const TODAY = isoDaysAgo(0); +const YEAR = Number(TODAY.slice(0, 4)); + +// --------------------------------------------------------------- org & people + +const company = { + name: "شرکت ساختمانی کابل", + legalName: "Kabul Construction Co. Ltd", + timezone: "Asia/Kabul", + currency: "AFN", + status: "ACTIVE", + plan: "PRO", + updatedAt: now, +}; + +const branches = [ + { + id: "br_main", + name: "دفتر مرکزی کابل", + code: "KBL-HQ", + address: "شهرنو، کابل", + latitude: 34.5553, + longitude: 69.2075, + radiusMeters: 250, + timezone: "Asia/Kabul", + status: "ACTIVE", + }, +]; + +const geofences = [ + { + id: "gf_main", + branchId: "br_main", + name: "دفتر مرکزی کابل", + latitude: 34.5553, + longitude: 69.2075, + radiusMeters: 250, + active: true, + }, +]; + +const departments = [ + { id: "dep_eng", name: "انجنیری", code: "ENG", branchId: "br_main" }, + { id: "dep_hr", name: "منابع بشری", code: "HR", branchId: "br_main" }, +]; + +const positions = [ + { id: "pos_mgr", title: "مدیر", code: "MGR", level: 5 }, + { id: "pos_eng", title: "انجنیر", code: "ENG", level: 3 }, +]; + +// The manager/admin is emp_admin; everyone else reports to them. +const employees = [ + { id: "emp_admin", employeeCode: "E-001", firstName: "احمد", lastName: "رحیمی", email: "admin@worktrack.af", dept: "dep_hr", pos: "pos_mgr", manager: null }, + { id: "emp_hr", employeeCode: "E-002", firstName: "زهرا", lastName: "نوری", email: "hr@worktrack.af", dept: "dep_hr", pos: "pos_mgr", manager: "emp_admin" }, + { id: "emp_ahmad", employeeCode: "E-003", firstName: "احمد", lastName: "کریمی", email: "ahmad@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_fatima", employeeCode: "E-004", firstName: "فاطمه", lastName: "احمدی", email: "fatima@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_omar", employeeCode: "E-005", firstName: "عمر", lastName: "صدیقی", email: "omar@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_yusuf", employeeCode: "E-006", firstName: "یوسف", lastName: "حبیبی", email: "yusuf@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_maryam", employeeCode: "E-007", firstName: "مریم", lastName: "رستمی", email: "maryam@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, +]; + +// Auth users -> custom claims. COMPANY_ADMIN sees everything; EMPLOYEE is the +// Android self-service login. +const authUsers = [ + { uid: "emp_admin", email: "admin@worktrack.af", name: "احمد رحیمی", roles: ["COMPANY_ADMIN"] }, + { uid: "emp_hr", email: "hr@worktrack.af", name: "زهرا نوری", roles: ["HR_ADMIN"] }, + { uid: "emp_ahmad", email: "ahmad@worktrack.af", name: "احمد کریمی", roles: ["EMPLOYEE"] }, +]; + +// -------------------------------------------------------------- leave & pay + +const leaveTypes = [ + { id: "lt_annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: "#2E7D32", isPaid: true, requiresAttachment: false }, + { id: "lt_sick", name: "رخصتی مریضی", code: "SICK", colorHex: "#B3261E", isPaid: true, requiresAttachment: false }, +]; + +const salaryComponents = [ + { id: "sc_basic", name: "معاش اساسی", code: "BASIC", type: "EARNING", calc: "FIXED", value: 25000, taxable: true, active: true }, + { id: "sc_transport", name: "کمک‌هزینه ترانسپورت", code: "TRANSPORT", type: "EARNING", calc: "FIXED", value: 3000, taxable: false, active: true }, + { id: "sc_tax", name: "مالیه معاش", code: "TAX", type: "DEDUCTION", calc: "PERCENT_OF_BASIC", value: 5, taxable: false, active: true }, +]; + +const announcements = [ + { + id: "ann_1", + title: "جلسهٔ عمومی کارمندان", + body: "روز یکشنبه ساعت ۱۰ صبح جلسهٔ عمومی در دفتر مرکزی برگزار می‌شود. حضور همه الزامی است.", + priority: "IMPORTANT", + createdByName: "احمد رحیمی", + }, + { + id: "ann_2", + title: "پرداخت معاش ماه", + body: "معاش این ماه تا آخر هفته به حساب‌ها واریز می‌شود.", + priority: "NORMAL", + createdByName: "زهرا نوری", + }, +]; + +// --------------------------------------------------------------------- writes + +async function seedOrg() { + await db.collection("companies").doc(CID).set(company); + + for (const b of branches) { + await col("branches").doc(b.id).set({ companyId: CID, ...b, updatedAt: now }); + } + for (const g of geofences) { + await col("geofences").doc(g.id).set({ companyId: CID, ...g, updatedAt: now }); + } + for (const d of departments) { + await col("departments").doc(d.id).set({ companyId: CID, ...d }); + } + for (const p of positions) { + await col("positions").doc(p.id).set({ companyId: CID, ...p }); + } + for (const e of employees) { + await col("employees").doc(e.id).set({ + companyId: CID, + employeeCode: e.employeeCode, + firstName: e.firstName, + lastName: e.lastName, + email: e.email, + phone: "+93 700 000 000", + avatarUrl: null, + branchId: "br_main", + departmentId: e.dept, + positionId: e.pos, + managerId: e.manager, + employmentType: "FULL_TIME", + joinDate: "2024-03-21", + status: "ACTIVE", + updatedAt: now, + }); + } +} + +async function seedAuth() { + for (const u of authUsers) { + try { + await auth.deleteUser(u.uid); + } catch { + // first run: nothing to delete + } + await auth.createUser({ + uid: u.uid, + email: u.email, + emailVerified: true, + password: PASSWORD, + displayName: u.name, + }); + await auth.setCustomUserClaims(u.uid, { + cid: CID, + eid: u.uid, + r: u.roles, + b: ["br_main"], + }); + } +} + +async function seedAttendance() { + // 7 days of attendance for every employee, with realistic variety. + for (let d = 6; d >= 0; d--) { + const iso = isoDaysAgo(d); + const weekday = new Date(`${iso}T00:00:00Z`).getUTCDay(); // 5 = Friday + employees.forEach((e, idx) => { + let status = "PRESENT"; + let lateMinutes = 0; + let firstInAt = at(iso, 8, 0); + let lastOutAt = at(iso, 16, 0); + let workedMinutes = 480; + + if (weekday === 5) { + status = "WEEK_OFF"; // Friday is the Afghan weekend + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (d === 0 && idx === 3) { + status = "ABSENT"; + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (d === 0 && idx === 4) { + status = "LEAVE"; + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (idx === 2 && (d === 0 || d === 2)) { + status = "PRESENT"; + lateMinutes = 25; + firstInAt = at(iso, 8, 25); + workedMinutes = 455; + } else if (d === 1 && idx === 5) { + status = "HALF_DAY"; + lastOutAt = at(iso, 12, 0); + workedMinutes = 240; + } + + col("attendanceDays").doc(`${e.id}_${iso}`).set({ + employeeId: e.id, + date: iso, + shiftId: null, + firstInAt, + lastOutAt, + workedMinutes, + lateMinutes, + earlyOutMinutes: 0, + overtimeMinutes: 0, + status, + computedAt: now, + updatedAt: now, + }); + }); + } +} + +async function seedLeave() { + for (const t of leaveTypes) { + await col("leaveTypes").doc(t.id).set({ companyId: CID, ...t, active: true, updatedAt: now }); + } + for (const e of employees) { + for (const t of leaveTypes) { + await col("leaveBalances").doc(`${e.id}_${t.id}_${YEAR}`).set({ + employeeId: e.id, + leaveTypeId: t.id, + periodYear: YEAR, + entitledDays: t.id === "lt_annual" ? 20 : 10, + accruedDays: 0, + usedDays: 2, + carriedOverDays: 0, + pendingDays: 0, + updatedAt: now, + }); + } + } + + // Pending requests routed to the admin so they show in the approvals queue. + const pending = [ + { id: "lr_1", emp: "emp_ahmad", name: "احمد کریمی", type: "lt_annual", start: isoDaysAgo(-3), end: isoDaysAgo(-5), days: 3, reason: "سفر خانوادگی به هرات" }, + { id: "lr_2", emp: "emp_fatima", name: "فاطمه احمدی", type: "lt_sick", start: isoDaysAgo(-1), end: isoDaysAgo(-1), days: 1, reason: "مریضی و مراجعه به داکتر" }, + { id: "lr_3", emp: "emp_omar", name: "عمر صدیقی", type: "lt_annual", start: isoDaysAgo(-7), end: isoDaysAgo(-9), days: 3, reason: "امور شخصی" }, + ]; + for (const r of pending) { + await col("leaveRequests").doc(r.id).set({ + companyId: CID, + employeeId: r.emp, + employeeName: r.name, + leaveTypeId: r.type, + startDate: r.start, + endDate: r.end, + startHalfDay: false, + endHalfDay: false, + days: r.days, + reason: r.reason, + status: "PENDING", + currentApproverId: "emp_admin", + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }); + } +} + +async function seedExtras() { + for (const c of salaryComponents) { + await col("salaryComponents").doc(c.id).set({ companyId: CID, ...c, updatedAt: now }); + } + for (const a of announcements) { + await col("announcements").doc(a.id).set({ + companyId: CID, + title: a.title, + body: a.body, + priority: a.priority, + publishedAt: now, + expiresAt: null, + createdByName: a.createdByName, + updatedAt: now, + }); + } + // Holiday calendar with the Afghan weekend note + a public holiday example. + await col("holidayCalendars").doc("hc_2026").set({ + companyId: CID, + name: "تقویم رخصتی ۱۴۰۵", + year: YEAR, + branchIds: ["br_main"], + weekendDays: ["FRIDAY"], + updatedAt: now, + }); +} + +async function main() { + console.log(`Seeding demo tenant into emulators (project=${PROJECT_ID})…`); + await seedOrg(); + await seedAuth(); + await seedAttendance(); + await seedLeave(); + await seedExtras(); + console.log("\n✅ Done. Sample logins (password: Passw0rd!):"); + console.log(" admin@worktrack.af — COMPANY_ADMIN (web portal)"); + console.log(" hr@worktrack.af — HR_ADMIN"); + console.log(" ahmad@worktrack.af — EMPLOYEE (Android app)"); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error("Seed failed:", err); + process.exit(1); + }); diff --git a/docs/11-local-demo-setup.md b/docs/11-local-demo-setup.md new file mode 100644 index 0000000..86b1318 --- /dev/null +++ b/docs/11-local-demo-setup.md @@ -0,0 +1,127 @@ +# راه‌اندازی محلی با داده نمونه (Local Demo Setup) + +این راهنما نشان می‌دهد چطور **بدون پروژهٔ واقعی Firebase و بدون هیچ هزینه‌ای**، کل +پلتفرم را روی کمپیوتر خودتان با داده نمونهٔ افغانی اجرا کنید — هم پورتال وب مدیر و +هم اپ اندروید. + +همه‌چیز با **Firebase Emulator Suite** (محلی) کار می‌کند. + +> English speakers: this is a step-by-step guide to run the whole platform locally +> against the Firebase Emulator Suite with a seeded Afghan demo tenant — no real +> Firebase project or billing required. Commands are the same regardless of language. + +--- + +## پیش‌نیازها + +- **Node.js 20+** و **npm** +- **Firebase CLI**: `npm install -g firebase-tools` +- **Java** (برای emulator فایرستور لازم است — معمولاً روی مک نصب است؛ در غیر این صورت `brew install openjdk`) + +--- + +## قدم ۱ — بک‌اند را بسازید و emulator را روشن کنید + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm install +``` + +یک فایل کوچک برای راز kiosk بسازید (تا emulator شکایت نکند): + +```zsh +echo 'KIOSK_HMAC_SECRET=demo-secret' > .secret.local +``` + +حالا emulator را با پروژهٔ نمونهٔ `demo-worktrack` روشن کنید: + +```zsh +cd ~/StudioProjects/WorkTrack/backend +firebase emulators:start --project demo-worktrack --only functions,firestore,auth +``` + +این ترمینال را **باز بگذارید**. باید ببینید که Functions روی `5001`، Firestore روی +`8080` و Auth روی `9099` اجرا شده‌اند. + +> اگر Firebase CLI از شما login خواست، `firebase login` را اجرا کنید. برای emulator +> نیازی به پروژهٔ واقعی نیست — پیشوند `demo-` یعنی هیچ منبع واقعی ساخته نمی‌شود. + +--- + +## قدم ۲ — داده نمونه را وارد کنید (Seed) + +یک ترمینال **جدید** باز کنید (emulator باید در حال اجرا بماند): + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm run seed +``` + +باید پیام موفقیت و لیست حساب‌ها را ببینید. این اسکریپت می‌سازد: + +- شرکت **«شرکت ساختمانی کابل»** با دفتر مرکزی در کابل (با geofence) +- **۷ کارمند** (شامل مدیر) +- **حاضری ۷ روز** (حاضر، ناوقت، غیرحاضر، نیم‌روز، جمعه تعطیل) +- **۳ درخواست رخصتی در انتظار** برای تست تاییدی +- انواع رخصتی، بیلانس، اعلانات، اجزای معاش (به افغانی) + +### حساب‌های ورود (رمز همه: `Passw0rd!`) + +| ایمیل | نقش | برای | +|---|---|---| +| `admin@worktrack.af` | COMPANY_ADMIN | پورتال وب | +| `hr@worktrack.af` | HR_ADMIN | پورتال وب | +| `ahmad@worktrack.af` | EMPLOYEE | اپ اندروید | + +--- + +## قدم ۳ — پورتال وب را اجرا کنید + +یک ترمینال جدید: + +```zsh +cd ~/StudioProjects/WorkTrack/web +npm install +cp .env.emulator .env.local +npm run dev +``` + +مرورگر را روی آدرسی که نشان می‌دهد باز کنید (مثلاً `http://localhost:5173/`). + +حالا با **`admin@worktrack.af`** و رمز **`Passw0rd!`** وارد شوید. باید ببینید: + +- **داشبورد**: آمار امروز (حاضر، غیرحاضر، ناوقت…) و نمودار روند ۷ روزهٔ شمسی +- **کارمندان**: لیست ۷ کارمند، جستجو، فرم افزودن +- **حاضری**: تختهٔ زندهٔ روزانه با وضعیت هر کارمند +- **رخصتی‌ها**: ۳ درخواست در انتظار — تایید یا رد کنید + +کلید بالای صفحه زبان را بین **دری / پښتو / English** عوض می‌کند. + +--- + +## قدم ۴ (اختیاری) — اپ اندروید را به emulator وصل کنید + +اپ اندروید فعلاً به Firebase تولیدی وصل می‌شود. برای وصل کردن آن به emulator محلی، +باید `google-services.json` (از قدم Firebase در README) اضافه شود و کد به Auth +emulator وصل شود. این یک قدم اضافی است — اگر می‌خواهید انجامش دهیم، بگویید تا +راهنمای آن را بنویسم. برای دیدن داده، **پورتال وب کافی است.** + +--- + +## توقف و ری‌ست + +- برای توقف: در هر ترمینال **Ctrl+C** بزنید. +- داده emulator در حافظه است و با توقف پاک می‌شود. برای داده تازه، دوباره + `npm run seed` را (وقتی emulator روشن است) اجرا کنید. + +--- + +## مشکلات رایج + +| مشکل | راه حل | +|---|---| +| پورتال «تنظیمات Firebase کامل نیست» نشان می‌دهد | `.env.local` را از `.env.emulator` کپی کرده‌اید؟ سرور dev را دوباره اجرا کنید. | +| ورود کار نمی‌کند / خطای شبکه | emulator روشن است؟ آیا `npm run seed` را اجرا کردید؟ | +| داشبورد خالی است | seed را دوباره اجرا کنید؛ مطمئن شوید project id در هر دو `demo-worktrack` است. | +| `firebase: command not found` | `npm install -g firebase-tools` | +| خطای Java در Firestore emulator | `brew install openjdk` (مک) | diff --git a/web/.env.emulator b/web/.env.emulator new file mode 100644 index 0000000..da649ba --- /dev/null +++ b/web/.env.emulator @@ -0,0 +1,15 @@ +# Local demo config — talks to the Firebase Emulator Suite, no real Firebase +# project needed. Copy this to .env.local to run the portal against the seeded +# demo tenant: cp .env.emulator .env.local + +VITE_USE_EMULATORS=true + +# Demo values are accepted by the Auth emulator as-is (project id must match the +# emulator project you start: --project demo-worktrack). +VITE_FIREBASE_API_KEY=demo-key +VITE_FIREBASE_AUTH_DOMAIN=demo-worktrack.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=demo-worktrack +VITE_FIREBASE_APP_ID=demo-app + +# Functions emulator URL (project id in the path must match too): +VITE_API_BASE_URL=http://127.0.0.1:5001/demo-worktrack/us-central1/api/v1 diff --git a/web/src/firebase.ts b/web/src/firebase.ts index 55bcdf9..f815184 100644 --- a/web/src/firebase.ts +++ b/web/src/firebase.ts @@ -1,5 +1,5 @@ import { initializeApp } from "firebase/app"; -import { getAuth, type Auth } from "firebase/auth"; +import { connectAuthEmulator, getAuth, type Auth } from "firebase/auth"; // Public web config — safe to ship in the client bundle. Access control is // enforced by the API (bearer token + RBAC), not by hiding these values. @@ -17,8 +17,16 @@ const config = { */ export const firebaseConfigured = Boolean(config.apiKey && config.projectId); +const useEmulators = import.meta.env.VITE_USE_EMULATORS === "true"; + // A stub is fine when unconfigured: the auth-dependent tree is never mounted // in that case (see main.tsx), so `auth` is never actually touched. export const auth: Auth = firebaseConfigured ? getAuth(initializeApp(config)) : ({} as Auth); + +// Local demo: talk to the Auth emulator instead of production Firebase, so +// the seeded demo users (see backend/functions/seed.js) can sign in. +if (firebaseConfigured && useEmulators) { + connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true }); +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 8eb70ec..c05a5ee 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_FIREBASE_AUTH_DOMAIN: string; readonly VITE_FIREBASE_PROJECT_ID: string; readonly VITE_FIREBASE_APP_ID: string; + readonly VITE_USE_EMULATORS?: string; } interface ImportMeta { From d60f49bc8f1fdf87c75a7bc67b20dec9693177e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:09:16 +0000 Subject: [PATCH 015/139] fix(demo): build functions before starting emulator in setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup guide started the emulator with a bare `firebase emulators:start`, which does NOT compile the TypeScript functions — so lib/index.js was missing, the `api` function never loaded, and the web portal login failed at GET /me with a generic error. - functions serve script now builds first and passes --config ../firebase.json --project demo-worktrack so it works when run from the functions dir - docs/11 terminal 1 uses `npm run serve` (build + start) instead of a bare emulators:start Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- backend/functions/package.json | 2 +- docs/11-local-demo-setup.md | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/functions/package.json b/backend/functions/package.json index 45d36d4..2e2baa3 100644 --- a/backend/functions/package.json +++ b/backend/functions/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "tsc", "watch": "tsc --watch", - "serve": "npm run build && firebase emulators:start --only functions,firestore,auth", + "serve": "npm run build && firebase emulators:start --config ../firebase.json --project demo-worktrack --only functions,firestore,auth", "seed": "node seed.js", "deploy": "firebase deploy --only functions", "typecheck": "tsc --noEmit" diff --git a/docs/11-local-demo-setup.md b/docs/11-local-demo-setup.md index 86b1318..06467c8 100644 --- a/docs/11-local-demo-setup.md +++ b/docs/11-local-demo-setup.md @@ -33,15 +33,19 @@ npm install echo 'KIOSK_HMAC_SECRET=demo-secret' > .secret.local ``` -حالا emulator را با پروژهٔ نمونهٔ `demo-worktrack` روشن کنید: +حالا emulator را روشن کنید. **از `npm run serve` استفاده کنید** — این دستور اول +کد TypeScript را build می‌کند و بعد emulator را با پروژهٔ `demo-worktrack` و +فایل تنظیمات درست اجرا می‌کند (build کردن الزامی است، وگرنه تابع `api` بارگذاری +نمی‌شود): ```zsh -cd ~/StudioProjects/WorkTrack/backend -firebase emulators:start --project demo-worktrack --only functions,firestore,auth +cd ~/StudioProjects/WorkTrack/backend/functions +npm run serve ``` -این ترمینال را **باز بگذارید**. باید ببینید که Functions روی `5001`، Firestore روی -`8080` و Auth روی `9099` اجرا شده‌اند. +این ترمینال را **باز بگذارید**. باید یک جدول با آدرس تابع `api` ببینید و در آخر +خط **`All emulators ready!`** (Functions روی `5001`، Firestore روی `8080`، +Auth روی `9099`). > اگر Firebase CLI از شما login خواست، `firebase login` را اجرا کنید. برای emulator > نیازی به پروژهٔ واقعی نیست — پیشوند `demo-` یعنی هیچ منبع واقعی ساخته نمی‌شود. From 5da26bff8e580e8f6dbe09ba2d72922f9f3bcb2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:21:18 +0000 Subject: [PATCH 016/139] feat(demo): one-command run-demo.sh (build + emulator + seed + portal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the demo previously meant juggling three or four terminals in the right order, which is confusing and error-prone. Add run-demo.sh: a single command that builds the backend, then uses `firebase emulators:exec` to start the emulators, seed the sample tenant, and launch the web portal in one lifecycle — Ctrl+C stops everything. Checks for firebase CLI and Java up front, and auto-creates .secret.local and web/.env.local on first run. docs/11 now leads with `bash run-demo.sh`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- docs/11-local-demo-setup.md | 27 +++++++++++++++++- run-demo.sh | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 run-demo.sh diff --git a/docs/11-local-demo-setup.md b/docs/11-local-demo-setup.md index 06467c8..a5e7a47 100644 --- a/docs/11-local-demo-setup.md +++ b/docs/11-local-demo-setup.md @@ -16,7 +16,32 @@ - **Node.js 20+** و **npm** - **Firebase CLI**: `npm install -g firebase-tools` -- **Java** (برای emulator فایرستور لازم است — معمولاً روی مک نصب است؛ در غیر این صورت `brew install openjdk`) +- **Java** (برای emulator فایرستور لازم است — از https://adoptium.net نصب کنید) + +--- + +## ⭐ ساده‌ترین راه: یک دستور + +به‌جای همهٔ قدم‌های پایین، فقط این را اجرا کنید: + +```zsh +cd ~/StudioProjects/WorkTrack +bash run-demo.sh +``` + +این اسکریپت خودش **همه‌چیز را به ترتیب درست** انجام می‌دهد: build بک‌اند، روشن +کردن emulator، وارد کردن داده نمونه، و اجرای پورتال — همه در **یک ترمینال**. +صبر کنید تا آدرس `http://localhost:...` چاپ شود، آن را در مرورگر باز کنید و با +`admin@worktrack.af` / `Passw0rd!` وارد شوید. + +برای **توقف**: یک بار **Ctrl+C** بزنید (همه‌چیز با هم بسته می‌شود). + +> اگر خطای «port in use» دیدید، یعنی یک emulator قدیمی هنوز باز است — همهٔ +> ترمینال‌های قبلی را ببندید و دوباره اجرا کنید. + +قدم‌های دستی پایین فقط برای وقتی است که بخواهید هر بخش را جدا اجرا کنید. + +--- --- diff --git a/run-demo.sh b/run-demo.sh new file mode 100755 index 0000000..8e2465c --- /dev/null +++ b/run-demo.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# One command to run the whole WorkTrack demo locally. +# +# bash run-demo.sh +# +# It builds the backend, starts the Firebase emulators, seeds the sample Afghan +# tenant, and launches the web manager portal — in the right order, in ONE +# terminal. Press Ctrl+C once to stop everything. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" + +echo "" +echo "==> WorkTrack demo starting. This uses local emulators only (no real Firebase)." +echo "" + +# --- 0. Prerequisites ------------------------------------------------------- +if ! command -v firebase >/dev/null 2>&1; then + echo "✗ Firebase CLI not found. Install it once with:" + echo " npm install -g firebase-tools" + exit 1 +fi +if ! command -v java >/dev/null 2>&1; then + echo "✗ Java not found (the Firestore emulator needs it)." + echo " Install Temurin JDK 21 from https://adoptium.net and re-run." + exit 1 +fi + +# --- 1. Backend ------------------------------------------------------------- +echo "==> Preparing backend…" +cd "$ROOT/backend/functions" +[ -d node_modules ] || { echo " installing backend deps (first run)…"; npm install --silent; } +[ -f .secret.local ] || echo 'KIOSK_HMAC_SECRET=demo-secret' > .secret.local +echo " building functions…" +npm run build --silent + +# --- 2. Web ----------------------------------------------------------------- +echo "==> Preparing web portal…" +cd "$ROOT/web" +[ -d node_modules ] || { echo " installing web deps (first run)…"; npm install --silent; } +[ -f .env.local ] || cp .env.emulator .env.local + +# --- 3. Emulators -> seed -> web (single lifecycle) ------------------------- +# emulators:exec starts the emulators, runs the inner command while they're up, +# and shuts them down when it exits. The inner command seeds the data and then +# runs the web dev server (which blocks until you press Ctrl+C). +echo "==> Starting emulators, seeding data, and launching the portal…" +echo " (first start takes ~20s; the portal URL will be printed below)" +echo "" +cd "$ROOT/backend" +firebase emulators:exec \ + --project demo-worktrack \ + --only functions,firestore,auth \ + "node \"$ROOT/backend/functions/seed.js\" && echo '' && echo '======================================================' && echo ' Portal starting — open the http://localhost URL below' && echo ' Login: admin@worktrack.af Password: Passw0rd!' && echo '======================================================' && echo '' && cd \"$ROOT/web\" && npm run dev" From fd366935acc9cc8f8832108eab98d4ddf3897fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:27:05 +0000 Subject: [PATCH 017/139] fix(web): Solar Hijri conversion used Math.floor instead of trunc-to-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web Shamsi converter divided with Math.floor, but the jalaali algorithm needs integer division that truncates toward zero (as Kotlin Int `/` does in the Android port). For negative operands — e.g. div(gm-8, 6) when the Gregorian month is before August — Math.floor(-1/6) = -1 while the algorithm needs 0, so every date came out wrong: the dashboard showed "undefined ۱۴۰۴ ۲۹" (month index out of range → undefined month name) and the wrong year. Introduce div() = Math.trunc(a/b) and use it for all integer divisions. Verified: 2026-07-18 -> 27 Saratan 1405, 2026-03-21 -> 1 Hamal 1405 (Nawruz). The Android/Kotlin version was already correct. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- web/src/shamsi/solarHijri.ts | 40 ++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/web/src/shamsi/solarHijri.ts b/web/src/shamsi/solarHijri.ts index 5179857..1381e3d 100644 --- a/web/src/shamsi/solarHijri.ts +++ b/web/src/shamsi/solarHijri.ts @@ -1,6 +1,11 @@ // Solar Hijri (هجری شمسی) <-> Gregorian conversion — the TypeScript port of the // Android app's core:common/time/SolarHijri.kt (jalaali break-year algorithm). // The business calendar of WorkTrack is Solar Hijri; storage/API stay ISO. +// +// IMPORTANT: this algorithm requires integer division that truncates toward +// ZERO (like Kotlin's Int `/`), NOT Math.floor — for negative operands (e.g. +// Gregorian months before August, gm-8 < 0) the two disagree and every derived +// date is wrong. Use div() below, never Math.floor, for the integer divisions. export interface ShamsiDate { year: number; @@ -8,6 +13,11 @@ export interface ShamsiDate { day: number; } +/** Integer division truncating toward zero (matches Kotlin Int `/`). */ +function div(a: number, b: number): number { + return Math.trunc(a / b); +} + const BREAKS = [ -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, @@ -29,18 +39,18 @@ function jalCal(jy: number): JalCal { const jm = BREAKS[i]; jump = jm - jp; if (jy < jm) break; - leapJ += Math.floor(jump / 33) * 8 + Math.floor((jump % 33) / 4); + leapJ += div(jump, 33) * 8 + div(jump % 33, 4); jp = jm; } let n = jy - jp; - leapJ += Math.floor(n / 33) * 8 + Math.floor(((n % 33) + 3) / 4); + leapJ += div(n, 33) * 8 + div((n % 33) + 3, 4); if (jump % 33 === 4 && jump - n === 4) leapJ += 1; - const leapG = Math.floor(gy / 4) - Math.floor((Math.floor(gy / 100) + 1) * 3 / 4) - 150; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; const march = 20 + leapJ - leapG; - if (jump - n < 6) n = n - jump + Math.floor((jump + 4) / 33) * 33; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; let leap = (((n + 1) % 33) - 1) % 4; if (leap === -1) leap = 4; @@ -49,27 +59,27 @@ function jalCal(jy: number): JalCal { function g2d(gy: number, gm: number, gd: number): number { let d = - Math.floor((gy + Math.floor((gm - 8) / 6) + 100100) * 1461 / 4) + - Math.floor((153 * ((gm + 9) % 12) + 2) / 5) + + div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + + div(153 * ((gm + 9) % 12) + 2, 5) + gd - 34840408; - d = d - Math.floor((Math.floor((gy + 100100 + Math.floor((gm - 8) / 6)) / 100) * 3) / 4) + 752; + d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; return d; } function d2g(jdn: number): { gy: number; gm: number; gd: number } { let j = 4 * jdn + 139361631; - j += Math.floor((Math.floor((4 * jdn + 183187720) / 146097) * 3) / 4) * 4 - 3908; - const i = Math.floor((j % 1461) / 4) * 5 + 308; - const gd = Math.floor((i % 153) / 5) + 1; - const gm = (Math.floor(i / 153) % 12) + 1; - const gy = Math.floor(j / 1461) - 100100 + Math.floor((8 - gm) / 6); + j += div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; + const i = div(j % 1461, 4) * 5 + 308; + const gd = div(i % 153, 5) + 1; + const gm = (div(i, 153) % 12) + 1; + const gy = div(j, 1461) - 100100 + div(8 - gm, 6); return { gy, gm, gd }; } function j2d(jy: number, jm: number, jd: number): number { const r = jalCal(jy); - return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - Math.floor(jm / 7) * (jm - 7) + jd - 1; + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; } function d2j(jdn: number): ShamsiDate { @@ -81,7 +91,7 @@ function d2j(jdn: number): ShamsiDate { if (k >= 0) { if (k <= 185) { - return { year: jy, month: 1 + Math.floor(k / 31), day: (k % 31) + 1 }; + return { year: jy, month: 1 + div(k, 31), day: (k % 31) + 1 }; } k -= 186; } else { @@ -89,7 +99,7 @@ function d2j(jdn: number): ShamsiDate { k += 179; if (r.leap === 1) k += 1; } - return { year: jy, month: 7 + Math.floor(k / 30), day: (k % 30) + 1 }; + return { year: jy, month: 7 + div(k, 30), day: (k % 30) + 1 }; } /** Parses an ISO date (YYYY-MM-DD) into its Solar Hijri equivalent. */ From bc97184adaf25ddac825ea1b372fa9ce0a755f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:34:54 +0000 Subject: [PATCH 018/139] feat(android): connect debug build to the local Firebase emulator Lets the Android app sign in against the seeded demo tenant (ahmad@worktrack.af) with no real Firebase project or google-services.json. - EmulatorConfig: when BuildConfig.USE_EMULATORS, initialize the default FirebaseApp with demo FirebaseOptions (no google-services.json needed) and route FirebaseAuth to the Auth emulator at 10.0.2.2:9099 - WorkTrackApplication.onCreate applies it before any auth usage - debug build: API_BASE_URL -> the demo-worktrack Functions emulator, and USE_EMULATORS=true (release stays false / production) - debug manifest overlay + network_security_config permit cleartext HTTP to 10.0.2.2 (emulators) so Android's default HTTPS-only policy doesn't block it - docs/11: real step-by-step for running the app against the emulator, including setting the AVD location to Kabul for GPS punch Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- app/build.gradle.kts | 9 +++- app/src/debug/AndroidManifest.xml | 8 ++++ .../debug/res/xml/network_security_config.xml | 11 +++++ .../app/worktrack/WorkTrackApplication.kt | 7 +++ .../app/worktrack/emulator/EmulatorConfig.kt | 46 +++++++++++++++++++ docs/11-local-demo-setup.md | 32 +++++++++++-- 6 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/res/xml/network_security_config.xml create mode 100644 app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b6f6b4..9117b5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,17 +13,22 @@ android { // Per-environment API endpoints are configured through build types below. buildConfigField("String", "API_BASE_URL", "\"https://api.worktrack.app/v1/\"") + // Production builds use real Firebase; debug overrides to the emulator. + buildConfigField("boolean", "USE_EMULATORS", "false") } buildTypes { debug { applicationIdSuffix = ".debug" + // Local demo against the Firebase Emulator Suite. 10.0.2.2 is the + // host loopback as seen from the Android emulator (AVD). Project id + // matches the demo tenant started by run-demo.sh. buildConfigField( "String", "API_BASE_URL", - // Firebase emulator suite / local functions host from an emulator. - "\"http://10.0.2.2:5001/worktrack-dev/us-central1/api/v1/\"", + "\"http://10.0.2.2:5001/demo-worktrack/us-central1/api/v1/\"", ) + buildConfigField("boolean", "USE_EMULATORS", "true") } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..23cbd1b --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..9f1e95f --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt index 9e3d374..f43881e 100644 --- a/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt +++ b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt @@ -6,6 +6,7 @@ import androidx.work.Configuration import app.worktrack.core.common.coroutines.ApplicationScope import app.worktrack.core.domain.repository.SyncScheduler import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.emulator.EmulatorConfig import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -32,6 +33,12 @@ class WorkTrackApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + + // Local demo: point Firebase Auth at the emulator before any auth call. + if (BuildConfig.USE_EMULATORS) { + EmulatorConfig.apply(this) + } + // Whenever a session exists (fresh sign-in or app restart), make sure the // periodic background sync is registered and kick one cycle immediately. observeSession() diff --git a/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt b/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt new file mode 100644 index 0000000..5358277 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt @@ -0,0 +1,46 @@ +package app.worktrack.emulator + +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth + +/** + * Wires the app to the local Firebase Emulator Suite for the offline demo, so + * the seeded users (see backend/functions/seed.js) can sign in without a real + * Firebase project or google-services.json. + * + * Applied only in debug builds (BuildConfig.USE_EMULATORS) from + * WorkTrackApplication.onCreate, before any Firebase Auth usage. + */ +object EmulatorConfig { + + private const val TAG = "EmulatorConfig" + + // 10.0.2.2 is the host machine's loopback as seen from the Android emulator. + private const val EMULATOR_HOST = "10.0.2.2" + private const val AUTH_EMULATOR_PORT = 9099 + + fun apply(context: Context) { + // Without google-services.json the default FirebaseApp never auto-inits, + // so create it here with demo options (any values are accepted by the + // Auth emulator; the project id must match the emulator's project). + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setProjectId("demo-worktrack") + .setApplicationId("1:1234567890:android:demoworktrack") + .setApiKey("demo-key") + .build(), + ) + } + + // Route Auth to the emulator. Safe to call once before any auth call; + // guarded so a process relaunch (or double init) doesn't crash. + runCatching { + FirebaseAuth.getInstance().useEmulator(EMULATOR_HOST, AUTH_EMULATOR_PORT) + }.onFailure { Log.w(TAG, "Auth emulator already configured: ${it.message}") } + } +} diff --git a/docs/11-local-demo-setup.md b/docs/11-local-demo-setup.md index a5e7a47..c25e525 100644 --- a/docs/11-local-demo-setup.md +++ b/docs/11-local-demo-setup.md @@ -128,12 +128,34 @@ npm run dev --- -## قدم ۴ (اختیاری) — اپ اندروید را به emulator وصل کنید +## قدم ۴ (اختیاری) — اپ اندروید را با همین داده اجرا کنید -اپ اندروید فعلاً به Firebase تولیدی وصل می‌شود. برای وصل کردن آن به emulator محلی، -باید `google-services.json` (از قدم Firebase در README) اضافه شود و کد به Auth -emulator وصل شود. این یک قدم اضافی است — اگر می‌خواهید انجامش دهیم، بگویید تا -راهنمای آن را بنویسم. برای دیدن داده، **پورتال وب کافی است.** +اپ اندروید (نسخهٔ **debug**) خودش به همین emulator محلی وصل می‌شود — **نیازی به +`google-services.json` نیست.** فقط باید emulator (همان ترمینال `run-demo.sh`) در +حال اجرا باشد. + +1. پروژه را در **Android Studio** باز کنید و `git pull` کنید (تا آخرین تغییرات را بگیرید). +2. اپ را روی **امولیتور اندروید (AVD)** اجرا کنید — دکمهٔ ▶ Run. +3. با این حساب وارد شوید: + - ایمیل: **`ahmad@worktrack.af`** + - رمز: **`Passw0rd!`** + +حالا داشبورد کارمند «احمد کریمی» را می‌بینید: بیلانس رخصتی، اعلانات، و در بخش +**تاریخچهٔ حاضری** ۷ روز حاضری (به تقویم شمسی). + +> اپ debug از طریق `10.0.2.2` (که آدرس کمپیوتر شما از داخل امولیتور است) به +> Auth روی `9099` و Functions روی `5001` وصل می‌شود. این فقط روی **امولیتور** +> اندروید کار می‌کند، نه گوشی واقعی. + +### حاضری با GPS (اختیاری) + +geofence روی **کابل** تنظیم شده، پس برای اینکه «ثبت ورود» کار کند باید موقعیت +امولیتور را به کابل بگذارید: + +- کنار پنجرهٔ امولیتور روی **`•••`** (Extended controls) کلیک کنید +- **Location** → مقدار **Latitude: `34.5553`** و **Longitude: `69.2075`** → **Set Location** + +بعد در اپ «ثبت ورود» بزنید — چون داخل ساحهٔ کاری هستید، حاضری ثبت و همگام می‌شود. --- From 2d26fa751d65b6afbc274e40728ce9c175a2f67a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:53:03 +0000 Subject: [PATCH 019/139] feat(payroll): calculation engine, portal page, and seeded run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the payroll feature end-to-end so the previously-empty payslips screen (Android) and a new portal page show real data. Backend: - lib/shamsi.ts: Solar Hijri month -> Gregorian date range (trunc-to-zero) - services/payroll.ts: per-employee payslip calc — BASIC (EmployeeSalary) + EARNING components as gross; DEDUCTION components + loss-of-pay for unpaid attendance days as deductions; net = gross - deductions; day counts from attendanceDays over the Shamsi month - routes/payroll.ts: GET /payroll/runs, POST /payroll/runs (idempotent, payroll:run), GET /payroll/runs/:id/payslips (payroll:read); wired into app.ts - seed: per-employee salaries + a pre-generated finalized run for the current Shamsi month so payslips are visible immediately (Android + portal) Web portal: - Payroll page: pick a Shamsi month and run payroll, list runs with totals, drill into a run's payslips per employee; nav item + RBAC gating (payroll:*) - trilingual strings; AFN amounts; Shamsi periods Backend typecheck + web build pass; seed syntax checked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- backend/functions/seed.js | 110 ++++++++++++- backend/functions/src/app.ts | 2 + backend/functions/src/lib/shamsi.ts | 124 +++++++++++++++ backend/functions/src/routes/payroll.ts | 121 ++++++++++++++ backend/functions/src/services/payroll.ts | 185 ++++++++++++++++++++++ web/README.md | 3 + web/src/App.tsx | 2 + web/src/api/hooks.ts | 30 ++++ web/src/api/types.ts | 36 +++++ web/src/auth/AuthProvider.tsx | 2 + web/src/i18n/strings.ts | 63 ++++++++ web/src/pages/PayrollPage.tsx | 169 ++++++++++++++++++++ web/src/ui/Layout.tsx | 1 + 13 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 backend/functions/src/lib/shamsi.ts create mode 100644 backend/functions/src/routes/payroll.ts create mode 100644 backend/functions/src/services/payroll.ts create mode 100644 web/src/pages/PayrollPage.tsx diff --git a/backend/functions/seed.js b/backend/functions/seed.js index e6560e5..0047101 100644 --- a/backend/functions/seed.js +++ b/backend/functions/seed.js @@ -49,6 +49,26 @@ function at(iso, hh, mm) { return Timestamp.fromDate(new Date(`${iso}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00Z`)); } +/** Compact Gregorian -> Solar Hijri (year, month) for the current payroll period. */ +function gregToShamsi(date) { + const div = (a, b) => Math.trunc(a / b); + const B = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]; + function jalCal(jy) { + const gy = jy + 621; let leapJ = -14, jp = B[0], jump = 0; + for (let i = 1; i < B.length; i++) { const jm = B[i]; jump = jm - jp; if (jy < jm) break; leapJ += div(jump, 33) * 8 + div(jump % 33, 4); jp = jm; } + let n = jy - jp; leapJ += div(n, 33) * 8 + div((n % 33) + 3, 4); if (jump % 33 === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; let leap = (((n + 1) % 33) - 1) % 4; if (leap === -1) leap = 4; + return { leap, gy, march }; + } + function g2d(gy, gm, gd) { let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * ((gm + 9) % 12) + 2, 5) + gd - 34840408; d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; return d; } + function d2g(jdn) { let j = 4 * jdn + 139361631; j += div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; const i = div(j % 1461, 4) * 5 + 308; const gm = (div(i, 153) % 12) + 1; const gy = div(j, 1461) - 100100 + div(8 - gm, 6); return { gy, gm }; } + const jdn = g2d(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); + const gy = d2g(jdn).gy; let jy = gy - 621; const r = jalCal(jy); const jdn1f = g2d(gy, 3, r.march); let k = jdn - jdn1f; + if (k >= 0) { if (k <= 185) return { year: jy, month: 1 + div(k, 31) }; k -= 186; } else { jy -= 1; k += 179; if (r.leap === 1) k += 1; } + return { year: jy, month: 7 + div(k, 30) }; +} + const TODAY = isoDaysAgo(0); const YEAR = Number(TODAY.slice(0, 4)); @@ -126,12 +146,25 @@ const leaveTypes = [ { id: "lt_sick", name: "رخصتی مریضی", code: "SICK", colorHex: "#B3261E", isPaid: true, requiresAttachment: false }, ]; +// BASIC comes from each employee's EmployeeSalary; these are the shared +// earning/deduction components layered on top. const salaryComponents = [ - { id: "sc_basic", name: "معاش اساسی", code: "BASIC", type: "EARNING", calc: "FIXED", value: 25000, taxable: true, active: true }, { id: "sc_transport", name: "کمک‌هزینه ترانسپورت", code: "TRANSPORT", type: "EARNING", calc: "FIXED", value: 3000, taxable: false, active: true }, + { id: "sc_food", name: "کمک‌هزینه غذا", code: "FOOD", type: "EARNING", calc: "FIXED", value: 2000, taxable: false, active: true }, { id: "sc_tax", name: "مالیه معاش", code: "TAX", type: "DEDUCTION", calc: "PERCENT_OF_BASIC", value: 5, taxable: false, active: true }, ]; +// Per-employee monthly basic salary (AFN). The manager (emp_admin) earns more. +const employeeSalaries = { + emp_admin: 45000, + emp_hr: 35000, + emp_ahmad: 28000, + emp_fatima: 26000, + emp_omar: 25000, + emp_yusuf: 24000, + emp_maryam: 23000, +}; + const announcements = [ { id: "ann_1", @@ -315,6 +348,80 @@ async function seedLeave() { } } +async function seedPayroll() { + // Per-employee basic salary. + for (const [empId, basic] of Object.entries(employeeSalaries)) { + await col("employeeSalaries").doc(empId).set({ + employeeId: empId, + structureId: null, + basicAmount: basic, + currency: "AFN", + effectiveFrom: "2024-03-21", + revisionReason: "Initial", + updatedAt: now, + }); + } + + // Pre-generate a finalized payroll run for the current Solar Hijri month so + // payslips are visible immediately in the portal and the employee app. The + // shape matches services/payroll.ts so a re-run from the portal overwrites it. + const sh = gregToShamsi(new Date()); + const runId = `${sh.year}_${String(sh.month).padStart(2, "0")}`; + let totalGross = 0; + let totalNet = 0; + let count = 0; + for (const e of employees) { + const basic = employeeSalaries[e.id]; + if (!basic) continue; + const tax = Math.round(basic * 0.05); + const lines = [ + { componentCode: "BASIC", componentName: "معاش اساسی", type: "EARNING", amount: basic }, + { componentCode: "TRANSPORT", componentName: "کمک‌هزینه ترانسپورت", type: "EARNING", amount: 3000 }, + { componentCode: "FOOD", componentName: "کمک‌هزینه غذا", type: "EARNING", amount: 2000 }, + { componentCode: "TAX", componentName: "مالیه معاش", type: "DEDUCTION", amount: tax }, + ]; + const gross = basic + 3000 + 2000; + const net = gross - tax; + await col("payslips").doc(`${e.id}_${runId}`).set({ + companyId: CID, + runId, + employeeId: e.id, + periodYear: sh.year, + periodMonth: sh.month, + currency: "AFN", + gross, + totalDeductions: tax, + net, + workedDays: 22, + paidLeaveDays: 0, + lopDays: 0, + overtimeMinutes: 0, + status: "FINALIZED", + pdfUrl: null, + lines, + updatedAt: now, + }); + totalGross += gross; + totalNet += net; + count += 1; + } + await col("payrollRuns").doc(runId).set({ + companyId: CID, + periodYear: sh.year, + periodMonth: sh.month, + status: "APPROVED", + startedBy: "emp_admin", + approvedBy: "emp_admin", + currency: "AFN", + payslipCount: count, + totalGross, + totalNet, + lockedAt: now, + createdAt: now, + updatedAt: now, + }); +} + async function seedExtras() { for (const c of salaryComponents) { await col("salaryComponents").doc(c.id).set({ companyId: CID, ...c, updatedAt: now }); @@ -349,6 +456,7 @@ async function main() { await seedAttendance(); await seedLeave(); await seedExtras(); + await seedPayroll(); console.log("\n✅ Done. Sample logins (password: Passw0rd!):"); console.log(" admin@worktrack.af — COMPANY_ADMIN (web portal)"); console.log(" hr@worktrack.af — HR_ADMIN"); diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts index 97f788e..b30b850 100644 --- a/backend/functions/src/app.ts +++ b/backend/functions/src/app.ts @@ -9,6 +9,7 @@ import { payslipsRouter } from "./routes/payslips"; import { announcementsRouter } from "./routes/announcements"; import { employeesRouter } from "./routes/employees"; import { analyticsRouter } from "./routes/analytics"; +import { payrollRouter } from "./routes/payroll"; import { syncRouter } from "./routes/sync"; /** @@ -34,6 +35,7 @@ export function createApp(): express.Express { v1.use("/attendance", attendanceRouter); v1.use("/leave", leaveRouter); v1.use("/payslips", payslipsRouter); + v1.use("/payroll", payrollRouter); v1.use("/announcements", announcementsRouter); v1.use("/analytics", analyticsRouter); v1.use("/sync", syncRouter); diff --git a/backend/functions/src/lib/shamsi.ts b/backend/functions/src/lib/shamsi.ts new file mode 100644 index 0000000..e2889a8 --- /dev/null +++ b/backend/functions/src/lib/shamsi.ts @@ -0,0 +1,124 @@ +/** + * Solar Hijri (هجری شمسی) helpers for payroll periods. Payroll runs are keyed by + * Shamsi year/month; this converts a Shamsi month to its Gregorian date range so + * attendance (stored as ISO dates) can be queried for that period. + * + * Integer division MUST truncate toward zero (jalaali algorithm) — never floor. + */ + +function div(a: number, b: number): number { + return Math.trunc(a / b); +} + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, + 2262, 2324, 2394, 2456, 3178, +]; + +interface JalCal { + leap: number; + gy: number; + march: number; +} + +function jalCal(jy: number): JalCal { + const gy = jy + 621; + let leapJ = -14; + let jp = BREAKS[0]; + let jump = 0; + for (let i = 1; i < BREAKS.length; i++) { + const jm = BREAKS[i]; + jump = jm - jp; + if (jy < jm) break; + leapJ += div(jump, 33) * 8 + div(jump % 33, 4); + jp = jm; + } + let n = jy - jp; + leapJ += div(n, 33) * 8 + div((n % 33) + 3, 4); + if (jump % 33 === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; + const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; + let leap = (((n + 1) % 33) - 1) % 4; + if (leap === -1) leap = 4; + return { leap, gy, march }; +} + +function g2d(gy: number, gm: number, gd: number): number { + let d = + div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + + div(153 * ((gm + 9) % 12) + 2, 5) + + gd - + 34840408; + d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; + return d; +} + +function d2g(jdn: number): { gy: number; gm: number; gd: number } { + let j = 4 * jdn + 139361631; + j += div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; + const i = div(j % 1461, 4) * 5 + 308; + const gd = div(i % 153, 5) + 1; + const gm = (div(i, 153) % 12) + 1; + const gy = div(j, 1461) - 100100 + div(8 - gm, 6); + return { gy, gm, gd }; +} + +function j2d(jy: number, jm: number, jd: number): number { + const r = jalCal(jy); + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; +} + +function iso(gy: number, gm: number, gd: number): string { + return `${gy.toString().padStart(4, "0")}-${gm.toString().padStart(2, "0")}-${gd + .toString() + .padStart(2, "0")}`; +} + +export function isShamsiLeapYear(year: number): boolean { + return jalCal(year).leap === 0; +} + +export function shamsiMonthLength(year: number, month: number): number { + if (month <= 6) return 31; + if (month <= 11) return 30; + return isShamsiLeapYear(year) ? 30 : 29; +} + +/** ISO date of the 1st of a Shamsi month. */ +export function shamsiMonthStartIso(year: number, month: number): string { + const g = d2g(j2d(year, month, 1)); + return iso(g.gy, g.gm, g.gd); +} + +/** ISO date of the last day of a Shamsi month. */ +export function shamsiMonthEndIso(year: number, month: number): string { + const g = d2g(j2d(year, month, shamsiMonthLength(year, month))); + return iso(g.gy, g.gm, g.gd); +} + +/** Current Shamsi (year, month) for defaulting a payroll period. */ +export function currentShamsiMonth(): { year: number; month: number } { + const now = new Date(); + const jdn = g2d(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate()); + const gy = d2g(jdn).gy; + let jy = gy - 621; + const r = jalCal(jy); + const jdn1f = g2d(gy, 3, r.march); + let k = jdn - jdn1f; + let month: number; + if (k >= 0) { + if (k <= 185) { + month = 1 + div(k, 31); + } else { + k -= 186; + month = 7 + div(k, 30); + } + } else { + jy -= 1; + k += 179; + if (r.leap === 1) k += 1; + month = 7 + div(k, 30); + } + return { year: jy, month }; +} diff --git a/backend/functions/src/routes/payroll.ts b/backend/functions/src/routes/payroll.ts new file mode 100644 index 0000000..0bd99b9 --- /dev/null +++ b/backend/functions/src/routes/payroll.ts @@ -0,0 +1,121 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { db, tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { checkIdempotency, recordIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { computePayrollRun } from "../services/payroll"; + +export const payrollRouter = Router(); + +/** Payroll runs for this company, newest first. */ +payrollRouter.get( + "/runs", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "payrollRuns").get(); + const runs = snapshot.docs + .map((doc) => { + const d = doc.data() as Record; + return { + id: doc.id, + periodYear: (d.periodYear as number) ?? 0, + periodMonth: (d.periodMonth as number) ?? 0, + status: (d.status as string) ?? "APPROVED", + currency: (d.currency as string) ?? "AFN", + payslipCount: (d.payslipCount as number) ?? 0, + totalGross: (d.totalGross as number) ?? 0, + totalNet: (d.totalNet as number) ?? 0, + lockedAt: toIso((d.lockedAt as Timestamp | null | undefined) ?? null), + createdAt: toIso((d.createdAt as Timestamp | null | undefined) ?? null), + }; + }) + .sort((a, b) => b.periodYear * 100 + b.periodMonth - (a.periodYear * 100 + a.periodMonth)); + res.json({ data: runs }); + }), +); + +const runCreateSchema = z.object({ + periodYear: z.number().int().min(1300).max(1500), + periodMonth: z.number().int().min(1).max(12), +}); + +/** Run (compute) payroll for a Solar Hijri month. Idempotent per period. */ +payrollRouter.post( + "/runs", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { periodYear, periodMonth } = parseBody(req, runCreateSchema); + + const idempotencyKey = req.header("Idempotency-Key"); + if (idempotencyKey) { + const replay = await checkIdempotency(auth.companyId, idempotencyKey); + if (replay !== null) { + res.json({ data: replay }); + return; + } + } + + const companySnap = await db.collection("companies").doc(auth.companyId).get(); + const currency = (companySnap.data()?.currency as string | undefined) ?? "AFN"; + + const result = await computePayrollRun( + auth.companyId, + periodYear, + periodMonth, + auth.employeeId, + currency, + ); + + if (idempotencyKey) { + await recordIdempotency(auth.companyId, idempotencyKey, result); + } + res.status(201).json({ data: result }); + }), +); + +/** Payslips generated in a run (manager view across employees). */ +payrollRouter.get( + "/runs/:runId/payslips", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const runSnap = await tenant(auth.companyId, "payrollRuns").doc(req.params.runId).get(); + if (!runSnap.exists) { + throw ApiError.notFound("Payroll run not found"); + } + const snapshot = await tenant(auth.companyId, "payslips") + .where("runId", "==", req.params.runId) + .get(); + + // Join employee names for the table (small runs; batched in prod). + const rows = await Promise.all( + snapshot.docs.map(async (doc) => { + const d = doc.data() as Record; + const empSnap = await tenant(auth.companyId, "employees") + .doc(d.employeeId as string) + .get(); + const emp = empSnap.data() as { firstName?: string; lastName?: string } | undefined; + return { + id: doc.id, + employeeId: d.employeeId, + employeeName: emp ? `${emp.firstName ?? ""} ${emp.lastName ?? ""}`.trim() : d.employeeId, + currency: d.currency, + gross: d.gross, + totalDeductions: d.totalDeductions, + net: d.net, + workedDays: d.workedDays, + lopDays: d.lopDays, + status: d.status, + }; + }), + ); + rows.sort((a, b) => String(a.employeeName).localeCompare(String(b.employeeName))); + res.json({ data: { runId: req.params.runId, payslips: rows } }); + }), +); diff --git a/backend/functions/src/services/payroll.ts b/backend/functions/src/services/payroll.ts new file mode 100644 index 0000000..dd4217a --- /dev/null +++ b/backend/functions/src/services/payroll.ts @@ -0,0 +1,185 @@ +import { nowTimestamp, tenant } from "../lib/firestore"; +import { shamsiMonthEndIso, shamsiMonthStartIso } from "../lib/shamsi"; + +/** + * Payroll calculation for one Solar Hijri month. + * + * For each active employee: BASIC (from their EmployeeSalary) plus every active + * EARNING component makes up gross; DEDUCTION components plus a loss-of-pay + * charge for unpaid absences make up deductions; net = gross − deductions. + * Day counts come from the attendanceDays projection over the month's Gregorian + * date range. + * + * At small/medium sizes this reads per-employee sequentially. For 100k-employee + * tenants this runs as a Cloud Tasks fan-out over BigQuery-sourced day counts + * (see docs/02); the payslip shape is identical, so clients are unaffected. + */ + +interface SalaryComponentDoc { + name: string; + code: string; + type: "EARNING" | "DEDUCTION" | "EMPLOYER_COST"; + calc: "FIXED" | "PERCENT_OF_BASIC" | "PERCENT_OF_GROSS"; + value: number; + active: boolean; +} + +interface PayslipLine { + componentCode: string; + componentName: string; + type: string; + amount: number; +} + +export interface PayrollRunResult { + runId: string; + periodYear: number; + periodMonth: number; + currency: string; + payslipCount: number; + totalNet: number; + totalGross: number; +} + +const LOP_DIVISOR = 30; // monthly salary / 30 per unpaid day (common in AF) + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +export async function computePayrollRun( + cid: string, + periodYear: number, + periodMonth: number, + startedBy: string, + currency: string, +): Promise { + const fromIso = shamsiMonthStartIso(periodYear, periodMonth); + const toIso = shamsiMonthEndIso(periodYear, periodMonth); + const runId = `${periodYear}_${String(periodMonth).padStart(2, "0")}`; + + const [employeesSnap, componentsSnap] = await Promise.all([ + tenant(cid, "employees").where("status", "==", "ACTIVE").get(), + tenant(cid, "salaryComponents").where("active", "==", true).get(), + ]); + + const components = componentsSnap.docs.map((d) => d.data() as SalaryComponentDoc); + const earnings = components.filter((c) => c.type === "EARNING"); + const deductions = components.filter((c) => c.type === "DEDUCTION"); + + let totalNet = 0; + let totalGross = 0; + let payslipCount = 0; + const now = nowTimestamp(); + + for (const empDoc of employeesSnap.docs) { + const employeeId = empDoc.id; + + const [salarySnap, daysSnap] = await Promise.all([ + tenant(cid, "employeeSalaries").doc(employeeId).get(), + tenant(cid, "attendanceDays") + .where("employeeId", "==", employeeId) + .where("date", ">=", fromIso) + .where("date", "<=", toIso) + .get(), + ]); + if (!salarySnap.exists) continue; // no salary on file → skip + const basic = (salarySnap.data()?.basicAmount as number | undefined) ?? 0; + + // Attendance-derived day counts for the period. + let workedDays = 0; + let paidLeaveDays = 0; + let lopDays = 0; + for (const dayDoc of daysSnap.docs) { + const status = (dayDoc.data().status as string) ?? ""; + if (status === "PRESENT") workedDays += 1; + else if (status === "HALF_DAY") { + workedDays += 0.5; + lopDays += 0.5; + } else if (status === "LEAVE") paidLeaveDays += 1; + else if (status === "ABSENT") lopDays += 1; + } + + // Earnings: BASIC + each active earning component. + const lines: PayslipLine[] = [ + { componentCode: "BASIC", componentName: "معاش اساسی", type: "EARNING", amount: round2(basic) }, + ]; + for (const c of earnings) { + const amount = c.calc === "PERCENT_OF_BASIC" ? (basic * c.value) / 100 : c.value; + lines.push({ componentCode: c.code, componentName: c.name, type: "EARNING", amount: round2(amount) }); + } + const gross = round2(lines.reduce((s, l) => s + l.amount, 0)); + + // Deductions: component deductions + loss-of-pay for unpaid days. + for (const c of deductions) { + let amount = c.value; + if (c.calc === "PERCENT_OF_BASIC") amount = (basic * c.value) / 100; + else if (c.calc === "PERCENT_OF_GROSS") amount = (gross * c.value) / 100; + lines.push({ componentCode: c.code, componentName: c.name, type: "DEDUCTION", amount: round2(amount) }); + } + if (lopDays > 0) { + lines.push({ + componentCode: "LOP", + componentName: "کسر غیرحاضری", + type: "DEDUCTION", + amount: round2((basic / LOP_DIVISOR) * lopDays), + }); + } + + const totalDeductions = round2( + lines.filter((l) => l.type === "DEDUCTION").reduce((s, l) => s + l.amount, 0), + ); + const net = round2(gross - totalDeductions); + + const payslipId = `${employeeId}_${runId}`; + await tenant(cid, "payslips").doc(payslipId).set({ + companyId: cid, + runId, + employeeId, + periodYear, + periodMonth, + currency, + gross, + totalDeductions, + net, + workedDays, + paidLeaveDays, + lopDays, + overtimeMinutes: 0, + status: "FINALIZED", + pdfUrl: null, + lines, + updatedAt: now, + }); + + totalGross += gross; + totalNet += net; + payslipCount += 1; + } + + await tenant(cid, "payrollRuns").doc(runId).set({ + companyId: cid, + periodYear, + periodMonth, + status: "APPROVED", + startedBy, + approvedBy: startedBy, + currency, + payslipCount, + totalGross: round2(totalGross), + totalNet: round2(totalNet), + lockedAt: now, + createdAt: now, + updatedAt: now, + }); + + return { + runId, + periodYear, + periodMonth, + currency, + payslipCount, + totalNet: round2(totalNet), + totalGross: round2(totalGross), + }; +} diff --git a/web/README.md b/web/README.md index 4fd3e80..376721d 100644 --- a/web/README.md +++ b/web/README.md @@ -18,6 +18,9 @@ calendar throughout. first-in time, worked hours, and lateness; date picker in Solar Hijri. - **Leave approvals** — pending-request queue with approve/reject (rejection requires a note, enforced server-side too). +- **Payroll** — run payroll for a Solar Hijri month (basic + earning components − + deductions − loss-of-pay from attendance), then view the generated payslips per + employee. Amounts in AFN; period is Shamsi. Gated on `payroll:read` / `payroll:run`. RBAC gates the sidebar and actions client-side for UX; the server is authoritative. diff --git a/web/src/App.tsx b/web/src/App.tsx index 91e59e7..34495a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { DashboardPage } from "./pages/DashboardPage"; import { EmployeesPage } from "./pages/EmployeesPage"; import { AttendancePage } from "./pages/AttendancePage"; import { LeavePage } from "./pages/LeavePage"; +import { PayrollPage } from "./pages/PayrollPage"; export function App() { const { status } = useAuth(); @@ -25,6 +26,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index d50ff4b..a1d8250 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -6,6 +6,9 @@ import type { EmployeeWrite, Kpis, LeaveRequest, + PayrollRun, + PayrollRunResult, + RunPayslipRow, TrendPoint, } from "./types"; @@ -57,6 +60,33 @@ export function useCreateEmployee() { }); } +export function usePayrollRuns() { + return useQuery({ + queryKey: ["payroll", "runs"], + queryFn: () => api.get("/payroll/runs").then((e) => e.data), + }); +} + +export function useRunPayslips(runId: string | null) { + return useQuery({ + enabled: runId !== null, + queryKey: ["payroll", "run", runId], + queryFn: () => + api + .get<{ runId: string; payslips: RunPayslipRow[] }>(`/payroll/runs/${runId}/payslips`) + .then((e) => e.data.payslips), + }); +} + +export function useRunPayroll() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { periodYear: number; periodMonth: number }) => + api.post("/payroll/runs", args).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["payroll"] }), + }); +} + export function usePendingApprovals() { return useQuery({ queryKey: ["leave", "approvals"], diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0a06875..19546bb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -100,6 +100,42 @@ export interface AttendanceOverviewRow { lateMinutes: number; } +export interface PayrollRun { + id: string; + periodYear: number; + periodMonth: number; + status: string; + currency: string; + payslipCount: number; + totalGross: number; + totalNet: number; + lockedAt: string | null; + createdAt: string | null; +} + +export interface PayrollRunResult { + runId: string; + periodYear: number; + periodMonth: number; + currency: string; + payslipCount: number; + totalNet: number; + totalGross: number; +} + +export interface RunPayslipRow { + id: string; + employeeId: string; + employeeName: string; + currency: string; + gross: number; + totalDeductions: number; + net: number; + workedDays: number; + lopDays: number; + status: string; +} + export interface LeaveRequest { id: string; companyId: string; diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx index fec1344..d4add40 100644 --- a/web/src/auth/AuthProvider.tsx +++ b/web/src/auth/AuthProvider.tsx @@ -121,4 +121,6 @@ const ROLE_PERMISSIONS: Record = { "employees:write": ["HR_ADMIN"], "attendance:read": ["HR_ADMIN", "PAYROLL_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], "leave:approve": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + "payroll:read": ["HR_ADMIN", "PAYROLL_ADMIN", "AUDITOR"], + "payroll:run": ["PAYROLL_ADMIN"], }; diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index f67f7b7..8caf2fb 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -20,8 +20,29 @@ const fa: Dict = { nav_employees: "کارمندان", nav_attendance: "حاضری", nav_leave: "رخصتی‌ها", + nav_payroll: "معاش", nav_logout: "خروج", + pay_title: "معاش", + pay_run: "اجرای معاش", + pay_running: "در حال محاسبه…", + pay_period: "دوره", + pay_year: "سال", + pay_month: "ماه", + pay_status: "وضعیت", + pay_employees: "کارمندان", + pay_total_gross: "مجموع ناخالص", + pay_total_net: "مجموع خالص", + pay_runs_empty: "هنوز معاشی اجرا نشده. یک ماه را انتخاب کنید و «اجرای معاش» را بزنید.", + pay_run_done: "معاش برای {0} کارمند محاسبه شد", + pay_view: "مشاهده", + pay_employee: "کارمند", + pay_gross: "ناخالص", + pay_deductions: "کسرات", + pay_net: "خالص", + pay_worked_days: "روزهای کارکرد", + pay_back_to_runs: "بازگشت به دوره‌ها", + login_email: "ایمیل کاری", login_password: "رمز عبور", login_submit: "ورود", @@ -111,8 +132,29 @@ const ps: Dict = { nav_employees: "کارکوونکي", nav_attendance: "حاضري", nav_leave: "رخصتۍ", + nav_payroll: "معاش", nav_logout: "وتل", + pay_title: "معاش", + pay_run: "د معاش اجرا", + pay_running: "محاسبه کېږي…", + pay_period: "دوره", + pay_year: "کال", + pay_month: "میاشت", + pay_status: "حالت", + pay_employees: "کارکوونکي", + pay_total_gross: "ټول ناخالص", + pay_total_net: "ټول خالص", + pay_runs_empty: "تر اوسه معاش نه دی اجرا شوی. یوه میاشت وټاکئ او «د معاش اجرا» کېکاږئ.", + pay_run_done: "معاش د {0} کارکوونکو لپاره محاسبه شو", + pay_view: "کتنه", + pay_employee: "کارکوونکی", + pay_gross: "ناخالص", + pay_deductions: "کسرات", + pay_net: "خالص", + pay_worked_days: "د کار ورځې", + pay_back_to_runs: "دورو ته بیرته", + login_email: "کاري برېښنالیک", login_password: "پټنوم", login_submit: "ننوتل", @@ -202,8 +244,29 @@ const en: Dict = { nav_employees: "Employees", nav_attendance: "Attendance", nav_leave: "Leave", + nav_payroll: "Payroll", nav_logout: "Sign out", + pay_title: "Payroll", + pay_run: "Run payroll", + pay_running: "Calculating…", + pay_period: "Period", + pay_year: "Year", + pay_month: "Month", + pay_status: "Status", + pay_employees: "Employees", + pay_total_gross: "Total gross", + pay_total_net: "Total net", + pay_runs_empty: "No payroll run yet. Pick a month and press “Run payroll”.", + pay_run_done: "Payroll calculated for {0} employees", + pay_view: "View", + pay_employee: "Employee", + pay_gross: "Gross", + pay_deductions: "Deductions", + pay_net: "Net", + pay_worked_days: "Worked days", + pay_back_to_runs: "Back to runs", + login_email: "Work email", login_password: "Password", login_submit: "Sign in", diff --git a/web/src/pages/PayrollPage.tsx b/web/src/pages/PayrollPage.tsx new file mode 100644 index 0000000..84db5c3 --- /dev/null +++ b/web/src/pages/PayrollPage.tsx @@ -0,0 +1,169 @@ +import { useState } from "react"; +import { usePayrollRuns, useRunPayroll, useRunPayslips } from "../api/hooks"; +import type { PayrollRun } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; +import { shamsiToday } from "../shamsi/solarHijri"; + +const SHAMSI_MONTHS_FA = [ + "حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", + "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت", +]; + +export function PayrollPage() { + const { t, num, locale, shamsiMonthName } = useI18n(); + const can = useHasPermission(); + const runs = usePayrollRuns(); + const runPayroll = useRunPayroll(); + + const today = shamsiToday(); + const [year, setYear] = useState(today.year); + const [month, setMonth] = useState(today.month); + const [openRun, setOpenRun] = useState(null); + const [toast, setToast] = useState(null); + + async function onRun() { + const result = await runPayroll.mutateAsync({ periodYear: year, periodMonth: month }); + setToast(t("pay_run_done", num(result.payslipCount))); + window.setTimeout(() => setToast(null), 2800); + } + + if (openRun) { + return setOpenRun(null)} />; + } + + return ( + <> +
+

{t("pay_title")}

+ {can("payroll:run") && ( +
+ + + +
+ )} +
+ + {runs.isLoading ? ( + + ) : runs.isError ? ( + void runs.refetch()} /> + ) : (runs.data?.length ?? 0) === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {runs.data!.map((run: PayrollRun) => ( + + + + + + + + + ))} + +
{t("pay_period")}{t("pay_status")}{t("pay_employees")}{t("pay_total_gross")}{t("pay_total_net")} +
+ {shamsiMonthName(run.periodMonth)} {locale === "en" ? run.periodYear : num(run.periodYear)} + + + {num(run.payslipCount)}{num(money(run.totalGross))} {run.currency}{num(money(run.totalNet))} {run.currency} + +
+
+ )} + {toast && } + + ); +} + +function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) { + const { t, num } = useI18n(); + const payslips = useRunPayslips(runId); + + return ( + <> +
+

{t("pay_title")}

+ +
+ + {payslips.isLoading ? ( + + ) : payslips.isError ? ( + void payslips.refetch()} /> + ) : ( +
+ + + + + + + + + + + + {payslips.data!.map((p) => ( + + + + + + + + ))} + +
{t("pay_employee")}{t("pay_gross")}{t("pay_deductions")}{t("pay_net")}{t("pay_worked_days")}
{p.employeeName}{num(money(p.gross))} {p.currency}{num(money(p.totalDeductions))} {p.currency}{num(money(p.net))} {p.currency}{num(p.workedDays)}
+
+ )} + + ); +} + +function money(n: number): string { + return n.toLocaleString("en-US"); +} diff --git a/web/src/ui/Layout.tsx b/web/src/ui/Layout.tsx index 50c6ca1..536777f 100644 --- a/web/src/ui/Layout.tsx +++ b/web/src/ui/Layout.tsx @@ -13,6 +13,7 @@ export function Layout() { { to: "/employees", icon: "◍", label: t("nav_employees"), show: can("employees:read") }, { to: "/attendance", icon: "◷", label: t("nav_attendance"), show: can("attendance:read") }, { to: "/leave", icon: "✈", label: t("nav_leave"), show: can("leave:approve") }, + { to: "/payroll", icon: "₼", label: t("nav_payroll"), show: can("payroll:read") }, ]; return ( From ce8f79750ff758ee3a0742a49f8b35ba19aed78b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:05:21 +0000 Subject: [PATCH 020/139] feat(signup): company self-registration + production deployment guide Turns WorkTrack into a real multi-tenant SaaS: each company registers itself and gets its own isolated workspace. Backend: - services/signup.ts + routes/public.ts: public POST /v1/public/signup (mounted before the auth middleware) provisions a company, a head-office branch, the founding COMPANY_ADMIN (employee record + Firebase Auth user with tenant claims), and default leave types/balances. Rejects duplicate emails. Hardening notes (email verification, rate limiting) in docs/12. Web (Company Console): - LoginPage now toggles between Sign in and "Register your company"; signupCompany() calls the public endpoint then signs the admin straight in. Trilingual strings (Dari/Pashto/English). Docs: - docs/12-production-deployment.md: clear Dari/English step-by-step to deploy to a real Firebase project (functions, rules, indexes, hosting, Android release) and onboard the first company via signup. - README states the two products explicitly (Company Console web / Employee App Android) and links the demo + production guides. Backend typecheck + web build pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- README.md | 12 ++ backend/functions/src/app.ts | 5 + backend/functions/src/routes/public.ts | 19 +++ backend/functions/src/services/signup.ts | 136 ++++++++++++++++++++ docs/12-production-deployment.md | 154 +++++++++++++++++++++++ web/src/api/client.ts | 19 +++ web/src/auth/LoginPage.tsx | 142 ++++++++++++++++----- web/src/i18n/strings.ts | 33 +++++ 8 files changed, 487 insertions(+), 33 deletions(-) create mode 100644 backend/functions/src/routes/public.ts create mode 100644 backend/functions/src/services/signup.ts create mode 100644 docs/12-production-deployment.md diff --git a/README.md b/README.md index e849fa9..17809f8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,17 @@ teams to 100,000+ employees. Dari is the default language (full Pashto and Engli translations, RTL-first UI), and all dates/payroll periods use the Solar Hijri calendar — see `docs/10-localization-afghanistan.md`. +**Two products, one backend.** Each company self-registers and gets its own +isolated workspace (multi-tenant): + +| Product | Audience | Where | +|---|---|---| +| **Company Console** | managers, HR, payroll | web portal (`web/`) — dashboard, employees, attendance, leave, payroll | +| **Employee App** | employees | Android app (`app/`) — attendance, leave, payslips | + +To take it live, see **[docs/12-production-deployment.md](docs/12-production-deployment.md)**. +To try it locally with sample data, see **[docs/11-local-demo-setup.md](docs/11-local-demo-setup.md)**. + ## Repository layout | Path | Contents | @@ -37,6 +48,7 @@ calendar — see `docs/10-localization-afghanistan.md`. 10. [Development roadmap](docs/09-roadmap.md) 11. [Afghanistan localization (دری/پښتو, Solar Hijri)](docs/10-localization-afghanistan.md) 12. [Local demo setup — run everything with sample data](docs/11-local-demo-setup.md) +13. [Production deployment — take it live](docs/12-production-deployment.md) ## Android app diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts index b30b850..a7528bc 100644 --- a/backend/functions/src/app.ts +++ b/backend/functions/src/app.ts @@ -10,6 +10,7 @@ import { announcementsRouter } from "./routes/announcements"; import { employeesRouter } from "./routes/employees"; import { analyticsRouter } from "./routes/analytics"; import { payrollRouter } from "./routes/payroll"; +import { publicRouter } from "./routes/public"; import { syncRouter } from "./routes/sync"; /** @@ -28,6 +29,10 @@ export function createApp(): express.Express { res.json({ data: { status: "ok" } }); }); + // Public, unauthenticated routes (company self-signup) — mounted BEFORE the + // auth middleware so a new company can be created without a token. + app.use("/v1/public", publicRouter); + const v1 = express.Router(); v1.use(requireAuth); v1.use("/me", meRouter); diff --git a/backend/functions/src/routes/public.ts b/backend/functions/src/routes/public.ts new file mode 100644 index 0000000..d19e421 --- /dev/null +++ b/backend/functions/src/routes/public.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { parseBody } from "../middleware/validate"; +import { companySignupSchema, provisionCompany } from "../services/signup"; + +/** + * Unauthenticated routes (mounted before the auth middleware). Keep this + * surface minimal — only self-service company signup lives here. + */ +export const publicRouter = Router(); + +publicRouter.post( + "/signup", + asyncHandler(async (req, res) => { + const input = parseBody(req, companySignupSchema); + const result = await provisionCompany(input); + res.status(201).json({ data: result }); + }), +); diff --git a/backend/functions/src/services/signup.ts b/backend/functions/src/services/signup.ts new file mode 100644 index 0000000..7f6903b --- /dev/null +++ b/backend/functions/src/services/signup.ts @@ -0,0 +1,136 @@ +import { getAuth } from "firebase-admin/auth"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +export const companySignupSchema = z.object({ + companyName: z.string().min(2).max(120), + adminFirstName: z.string().min(1).max(80), + adminLastName: z.string().min(1).max(80), + email: z.string().email(), + password: z.string().min(8).max(100), + timezone: z.string().default("Asia/Kabul"), + currency: z.string().length(3).default("AFN"), +}); + +export type CompanySignup = z.infer; + +export interface SignupResult { + companyId: string; + employeeId: string; +} + +/** + * Provisions a brand-new tenant: a company, its head-office branch, the founding + * COMPANY_ADMIN (both an employee record and a Firebase Auth user with tenant + * claims), and sensible default leave types. This is the "each company gets its + * own workspace" entry point. + * + * NOTE for production: gate this behind email verification and abuse controls + * (rate limiting / captcha) before public launch — see docs/12. + */ +export async function provisionCompany(input: CompanySignup): Promise { + const auth = getAuth(); + + // Reject if the email is already registered anywhere on the platform. + const existing = await auth.getUserByEmail(input.email).catch(() => null); + if (existing) { + throw new ApiError(409, ErrorCodes.CONFLICT, "An account with this email already exists"); + } + + const companyId = ulid(); + const employeeId = ulid(); + const branchId = ulid(); + const now = nowTimestamp(); + + // 1. Company + await db.collection("companies").doc(companyId).set({ + name: input.companyName, + legalName: input.companyName, + timezone: input.timezone, + currency: input.currency, + status: "ACTIVE", + plan: "FREE", + createdAt: now, + updatedAt: now, + }); + + // 2. Head-office branch (managers configure geofences/shifts on it later) + await tenant(companyId, "branches").doc(branchId).set({ + companyId, + name: "دفتر مرکزی", + code: "HQ", + address: null, + latitude: null, + longitude: null, + radiusMeters: null, + timezone: input.timezone, + status: "ACTIVE", + updatedAt: now, + }); + + // 3. Founding admin employee + await tenant(companyId, "employees").doc(employeeId).set({ + companyId, + employeeCode: "E-001", + firstName: input.adminFirstName, + lastName: input.adminLastName, + email: input.email, + phone: null, + avatarUrl: null, + branchId, + departmentId: null, + positionId: null, + managerId: null, + employmentType: "FULL_TIME", + joinDate: new Date().toISOString().slice(0, 10), + status: "ACTIVE", + updatedAt: now, + }); + + // 4. Default leave types so leave works out of the box + const leaveTypes = [ + { id: "annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: "#2E7D32", entitled: 20 }, + { id: "sick", name: "رخصتی مریضی", code: "SICK", colorHex: "#B3261E", entitled: 10 }, + ]; + for (const lt of leaveTypes) { + await tenant(companyId, "leaveTypes").doc(lt.id).set({ + companyId, + name: lt.name, + code: lt.code, + colorHex: lt.colorHex, + isPaid: true, + requiresAttachment: false, + active: true, + updatedAt: now, + }); + await tenant(companyId, "leaveBalances").doc(`${employeeId}_${lt.id}_${new Date().getUTCFullYear()}`).set({ + employeeId, + leaveTypeId: lt.id, + periodYear: new Date().getUTCFullYear(), + entitledDays: lt.entitled, + accruedDays: 0, + usedDays: 0, + carriedOverDays: 0, + pendingDays: 0, + updatedAt: now, + }); + } + + // 5. Firebase Auth user + tenant claims (this is what the token carries) + await auth.createUser({ + uid: employeeId, + email: input.email, + password: input.password, + displayName: `${input.adminFirstName} ${input.adminLastName}`.trim(), + }); + await auth.setCustomUserClaims(employeeId, { + cid: companyId, + eid: employeeId, + r: ["COMPANY_ADMIN"], + b: [branchId], + }); + + return { companyId, employeeId }; +} diff --git a/docs/12-production-deployment.md b/docs/12-production-deployment.md new file mode 100644 index 0000000..44d2b6a --- /dev/null +++ b/docs/12-production-deployment.md @@ -0,0 +1,154 @@ +# رفتن به Production (نصب واقعی) + +این راهنما نشان می‌دهد چطور WorkTrack را از حالت محلی به یک **پروژهٔ واقعی Firebase** +ببرید تا شرکت‌های واقعی بتوانند استفاده کنند. ساده و مرحله‌به‌مرحله. + +> English: step-by-step guide to deploy WorkTrack to a real Firebase project. + +--- + +## دو محصول (نسخهٔ شرکت / نسخهٔ کارمند) + +WorkTrack دو نسخه دارد که هر دو از یک بک‌اند استفاده می‌کنند: + +| محصول | برای چه کسی | چیست | +|---|---|---| +| **پورتال شرکت** (`web/`) | مدیر، منابع بشری، معاش | وب‌سایت مدیریت — داشبورد، کارمندان، حاضری، رخصتی، معاش | +| **اپ کارمند** (`app/`) | کارمندان | اپ اندروید — حاضری، رخصتی، فیش معاش | + +هر شرکت **خودش در پورتال ثبت‌نام می‌کند** و فضای کاری جدا و امن خودش را می‌گیرد +(multi-tenant). داده هیچ شرکتی برای شرکت دیگر دیده نمی‌شود. + +--- + +## پیش‌نیازها + +- یک حساب Google +- **Firebase CLI**: `npm install -g firebase-tools` سپس `firebase login` +- برای Cloud Functions، پروژه باید روی پلن **Blaze** باشد (پرداخت به‌اندازهٔ مصرف؛ + استفادهٔ کم معمولاً رایگان است) + +--- + +## قدم ۱ — ساخت پروژهٔ Firebase + +1. به بروید و **Add project** را بزنید + (مثلاً نام: `worktrack-prod`). +2. در **Build → Authentication → Sign-in method**، گزینهٔ **Email/Password** را + **Enable** کنید. +3. در **Build → Firestore Database**، یک دیتابیس بسازید (production mode). +4. پروژه را به پلن **Blaze** ارتقا دهید (منوی پایین چپ، Upgrade). + +پروژه را به مخزن وصل کنید: + +```zsh +cd ~/StudioProjects/WorkTrack/backend +cp .firebaserc.example .firebaserc +``` +بعد داخل `.firebaserc`، به‌جای `worktrack-prod` شناسهٔ واقعی پروژهٔ خود را بگذارید. + +--- + +## قدم ۲ — استقرار بک‌اند (API + قوانین + ایندکس) + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm install +npm run build + +# راز kiosk را در Secret Manager بگذارید (یک بار): +firebase functions:secrets:set KIOSK_HMAC_SECRET + +cd ~/StudioProjects/WorkTrack/backend +firebase deploy --only functions,firestore:rules,firestore:indexes --project +``` + +بعد از استقرار، آدرس تابع `api` را یادداشت کنید — چیزی مثل: +`https://us-central1-.cloudfunctions.net/api` + +--- + +## قدم ۳ — استقرار پورتال شرکت (وب) + +1. در Firebase console → **Project settings → General → Your apps** یک اپ **Web** + بسازید و مقادیر config آن را بردارید. +2. یک فایل `web/.env.production` بسازید: + +``` +VITE_API_BASE_URL=/v1 +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN=.firebaseapp.com +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_APP_ID= +``` + +> `VITE_API_BASE_URL=/v1` کار می‌کند چون Hosting درخواست‌های `/v1/**` را به تابع +> `api` هدایت می‌کند (در `backend/firebase.json` تنظیم شده) — بدون مشکل CORS. + +3. build و deploy: + +```zsh +cd ~/StudioProjects/WorkTrack/web +npm install +npm run build +cd ~/StudioProjects/WorkTrack/backend +firebase deploy --only hosting --project +``` + +پورتال حالا روی `https://.web.app` در دسترس است. + +--- + +## قدم ۴ — اولین شرکت را ثبت‌نام کنید + +نیازی به اسکریپت seed نیست! به پورتال بروید، روی **«شرکت جدید؟ ثبت‌نام کنید»** +کلیک کنید و فرم را پر کنید (نام شرکت، نام مدیر، ایمیل، رمز). فضای کاری شرکت، +شعبهٔ «دفتر مرکزی»، انواع رخصتی پیش‌فرض و حساب مدیر به‌صورت خودکار ساخته می‌شود. +بعد وارد شوید و کارمندان را اضافه کنید. + +--- + +## قدم ۵ — اپ کارمند (اندروید) + +1. در Firebase console → **Add app → Android**: + - Package name برای نسخهٔ عرضه: **`app.worktrack`** + - (برای تست، `app.worktrack.debug` را هم اضافه کنید) +2. فایل **`google-services.json`** را دانلود و در پوشهٔ **`app/`** بگذارید. +3. آدرس API نسخهٔ عرضه از قبل روی `https://api.worktrack.app/v1/` است؛ اگر دامنهٔ + دلخواه ندارید، در `app/build.gradle.kts` (بخش `defaultConfig`) آن را به آدرس + تابع خود تغییر دهید: + `https://us-central1-.cloudfunctions.net/api/v1/` +4. در Android Studio: **Build → Generate Signed Bundle / APK** → یک keystore + بسازید → **release** → فایل `.aab` را بسازید. +5. `.aab` را در **Google Play Console** آپلود کنید. + +> نسخهٔ **release** به Firebase واقعی وصل می‌شود (نه emulator). نسخهٔ **debug** +> برای تست به emulator محلی وصل می‌ماند. + +--- + +## قدم ۶ — کارهای امنیتی پیش از عرضهٔ عمومی + +این‌ها را قبل از باز کردن ثبت‌نام عمومی انجام دهید (در `docs/07` مفصل آمده): + +- **تأیید ایمیل** برای ثبت‌نام شرکت (جلوگیری از حساب‌های جعلی) +- **محدودسازی نرخ** (rate limiting) روی `POST /v1/public/signup` و ورود +- **App Check** برای اپ اندروید و پورتال وب +- مرور **قوانین Firestore** و **کاتالوگ دسترسی‌ها** (RBAC) +- **بکاپ** خودکار Firestore و سیاست نگه‌داری داده + +--- + +## خلاصهٔ دستورها + +```zsh +# بک‌اند +cd backend/functions && npm run build +cd backend && firebase deploy --only functions,firestore --project + +# پورتال وب +cd web && npm run build +cd backend && firebase deploy --only hosting --project + +# اپ کارمند: Android Studio → Signed Bundle → Play Console +``` diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 07ab297..d0388ee 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -100,3 +100,22 @@ export const api = { put: (path: string, body: unknown) => request>(path, { method: "PUT", body }), }; + +/** Public (unauthenticated) endpoints — no bearer token attached. */ +export async function signupCompany(body: { + companyName: string; + adminFirstName: string; + adminLastName: string; + email: string; + password: string; +}): Promise<{ companyId: string; employeeId: string }> { + const response = await fetch(`${BASE_URL}/public/signup`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw await toApiError(response); + } + return ((await response.json()) as Envelope<{ companyId: string; employeeId: string }>).data; +} diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx index dd48560..9592f38 100644 --- a/web/src/auth/LoginPage.tsx +++ b/web/src/auth/LoginPage.tsx @@ -1,12 +1,20 @@ import { useState, type FormEvent } from "react"; import { FirebaseError } from "firebase/app"; import { NoManagerAccessError, useAuth } from "./AuthProvider"; +import { ApiError, signupCompany } from "../api/client"; import { useI18n } from "../i18n/LocaleProvider"; import { LOCALES } from "../i18n/strings"; +type Mode = "login" | "signup"; + export function LoginPage() { const { signIn } = useAuth(); const { t, locale, setLocale } = useI18n(); + const [mode, setMode] = useState("login"); + + const [companyName, setCompanyName] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); @@ -18,55 +26,87 @@ export function LoginPage() { setBusy(true); setError(null); try { + if (mode === "signup") { + // Create the company + admin, then sign that admin straight in. + await signupCompany({ + companyName, + adminFirstName: firstName, + adminLastName: lastName, + email, + password, + }); + } await signIn(email, password); } catch (err) { - if (err instanceof NoManagerAccessError) { - setError(t("login_no_access")); - } else if (err instanceof FirebaseError) { - setError(t("login_error")); - } else { - setError(t("common_error")); - } + if (err instanceof NoManagerAccessError) setError(t("login_no_access")); + else if (err instanceof ApiError) + setError(err.code === "CONFLICT" ? t("signup_email_exists") : err.message); + else if (err instanceof FirebaseError) setError(t("login_error")); + else setError(t("common_error")); } finally { setBusy(false); } } + const isSignup = mode === "signup"; + return (
WorkTrack
-
{t("tagline")}
+
{isSignup ? t("signup_title") : t("tagline")}
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
+ {isSignup && ( + <> + +
+ + +
+ + )} + + + {error &&
{error}
} + +
@@ -85,3 +125,39 @@ export function LoginPage() {
); } + +function Field({ + label, + value, + onChange, + type = "text", + autoComplete, + dir, + hint, + required, +}: { + label: string; + value: string; + onChange: (v: string) => void; + type?: string; + autoComplete?: string; + dir?: "ltr" | "rtl"; + hint?: string; + required?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + required={required} + /> + {hint && {hint}} +
+ ); +} diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index 8caf2fb..bbb1888 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -50,6 +50,17 @@ const fa: Dict = { login_no_access: "این حساب دسترسی مدیریتی ندارد", login_signing_in: "در حال ورود…", + signup_title: "ثبت‌نام شرکت", + signup_company: "نام شرکت", + signup_admin_first: "نام مدیر", + signup_admin_last: "تخلص مدیر", + signup_submit: "ایجاد فضای کاری", + signup_creating: "در حال ایجاد…", + signup_no_account: "شرکت جدید؟ ثبت‌نام کنید", + signup_have_account: "حساب دارید؟ وارد شوید", + signup_email_exists: "این ایمیل قبلاً ثبت شده است", + signup_password_hint: "حداقل ۸ حرف", + dash_title: "نمای کلی امروز", dash_active_employees: "کارمندان فعال", dash_present: "حاضر", @@ -162,6 +173,17 @@ const ps: Dict = { login_no_access: "دا حساب مدیریتي لاسرسی نه لري", login_signing_in: "ننوتل کېږي…", + signup_title: "د شرکت ثبت", + signup_company: "د شرکت نوم", + signup_admin_first: "د مدیر نوم", + signup_admin_last: "د مدیر تخلص", + signup_submit: "د کاري ځای جوړول", + signup_creating: "جوړېږي…", + signup_no_account: "نوی شرکت؟ ثبت نام وکړئ", + signup_have_account: "حساب لرئ؟ ننوځئ", + signup_email_exists: "دا برېښنالیک له مخکې ثبت شوی", + signup_password_hint: "لږ تر لږه ۸ توري", + dash_title: "د نن ورځې لنډیز", dash_active_employees: "فعال کارکوونکي", dash_present: "حاضر", @@ -274,6 +296,17 @@ const en: Dict = { login_no_access: "This account has no manager access", login_signing_in: "Signing in…", + signup_title: "Register your company", + signup_company: "Company name", + signup_admin_first: "Admin first name", + signup_admin_last: "Admin last name", + signup_submit: "Create workspace", + signup_creating: "Creating…", + signup_no_account: "New company? Register", + signup_have_account: "Have an account? Sign in", + signup_email_exists: "This email is already registered", + signup_password_hint: "At least 8 characters", + dash_title: "Today at a glance", dash_active_employees: "Active employees", dash_present: "Present", From 24e12d87a3964104983700942c642ae5f59d1564 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:19:31 +0000 Subject: [PATCH 021/139] feat(invite): adding an employee creates their mobile-app login Closes the gap where a manager could add an employee record but the employee had no way to sign into the app. Backend: - services/invite.ts: createEmployeeLogin (Firebase Auth user with uid = employeeId + tenant/RBAC claims) and resetEmployeePassword; readable temp-password generator. Assignable roles exclude COMPANY_ADMIN. - POST /employees now provisions the login (login created before the doc so a duplicate email doesn't orphan a record) and returns a one-time tempPassword; role/createLogin/initialPassword added to the schema. Doc is built explicitly so login fields never leak into the employee document. - POST /employees/:id/reset-password issues a fresh temp password. Web (Company Console): - Add-employee form gains a role dropdown, a "create mobile login" toggle, and an optional password; on success a credentials dialog shows the email + temp password (with copy) for the manager to share. Trilingual strings. Backend typecheck + web build pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2 --- backend/functions/src/routes/employees.ts | 97 +++++++++++---- backend/functions/src/services/invite.ts | 78 ++++++++++++ web/src/api/hooks.ts | 4 +- web/src/api/types.ts | 16 +++ web/src/i18n/strings.ts | 48 ++++++++ web/src/pages/EmployeesPage.tsx | 139 ++++++++++++++++++++-- 6 files changed, 349 insertions(+), 33 deletions(-) create mode 100644 backend/functions/src/services/invite.ts diff --git a/backend/functions/src/routes/employees.ts b/backend/functions/src/routes/employees.ts index 325325c..88152cc 100644 --- a/backend/functions/src/routes/employees.ts +++ b/backend/functions/src/routes/employees.ts @@ -8,6 +8,11 @@ import { ulid } from "../lib/ids"; import { authOf } from "../middleware/auth"; import { requirePermission } from "../middleware/rbac"; import { parseBody } from "../middleware/validate"; +import { + ASSIGNABLE_ROLES, + createEmployeeLogin, + resetEmployeePassword, +} from "../services/invite"; export const employeesRouter = Router(); @@ -129,8 +134,34 @@ const employeeWriteSchema = z.object({ employmentType: z.enum(["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]), joinDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), status: z.enum(["ACTIVE", "ON_LEAVE", "SUSPENDED", "EXITED"]).default("ACTIVE"), + // Login provisioning (create only): give the new employee a mobile-app login. + role: z.enum(ASSIGNABLE_ROLES).default("EMPLOYEE"), + createLogin: z.boolean().default(true), + initialPassword: z.string().min(8).max(100).optional(), }); +function toDoc( + payload: z.infer, + avatarUrl: string | null, +): EmployeeDoc { + return { + employeeCode: payload.employeeCode, + firstName: payload.firstName, + lastName: payload.lastName, + email: payload.email, + phone: payload.phone ?? null, + branchId: payload.branchId ?? null, + departmentId: payload.departmentId ?? null, + positionId: payload.positionId ?? null, + managerId: payload.managerId ?? null, + employmentType: payload.employmentType, + joinDate: payload.joinDate, + status: payload.status, + avatarUrl, + updatedAt: nowTimestamp(), + }; +} + employeesRouter.post( "/", requirePermission("employees:write"), @@ -138,16 +169,23 @@ employeesRouter.post( const auth = authOf(req); const payload = parseBody(req, employeeWriteSchema); const id = ulid(); - const doc: EmployeeDoc = { - ...payload, - phone: payload.phone ?? null, - branchId: payload.branchId ?? null, - departmentId: payload.departmentId ?? null, - positionId: payload.positionId ?? null, - managerId: payload.managerId ?? null, - avatarUrl: null, - updatedAt: nowTimestamp(), - }; + const doc = toDoc(payload, null); + + // Create the login FIRST so a duplicate-email failure doesn't leave an + // orphaned employee record behind. + let tempPassword: string | null = null; + if (payload.createLogin) { + tempPassword = await createEmployeeLogin({ + companyId: auth.companyId, + employeeId: id, + email: payload.email, + displayName: `${payload.firstName} ${payload.lastName}`.trim(), + role: payload.role, + branchIds: doc.branchId ? [doc.branchId] : [], + password: payload.initialPassword, + }); + } + await tenant(auth.companyId, "employees").doc(id).create(doc); await audit(auth.companyId, { actorId: auth.employeeId, @@ -155,9 +193,33 @@ employeesRouter.post( action: "employees.create", resourceType: "employees", resourceId: id, - after: { employeeCode: payload.employeeCode, email: payload.email }, + after: { employeeCode: payload.employeeCode, email: payload.email, role: payload.role }, + }); + res.status(201).json({ + data: { ...employeeToDto(id, auth.companyId, doc), tempPassword }, + }); + }), +); + +/** Reset an employee's login to a fresh temporary password (manager shares it). */ +employeesRouter.post( + "/:id/reset-password", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const emp = await tenant(auth.companyId, "employees").doc(req.params.id).get(); + if (!emp.exists) { + throw ApiError.notFound("Employee not found"); + } + const tempPassword = await resetEmployeePassword(req.params.id); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.reset_password", + resourceType: "employees", + resourceId: req.params.id, }); - res.status(201).json({ data: employeeToDto(id, auth.companyId, doc) }); + res.json({ data: { tempPassword } }); }), ); @@ -172,16 +234,7 @@ employeesRouter.put( if (!existing.exists) { throw ApiError.notFound("Employee not found"); } - const doc: EmployeeDoc = { - ...payload, - phone: payload.phone ?? null, - branchId: payload.branchId ?? null, - departmentId: payload.departmentId ?? null, - positionId: payload.positionId ?? null, - managerId: payload.managerId ?? null, - avatarUrl: (existing.data() as EmployeeDoc).avatarUrl ?? null, - updatedAt: nowTimestamp(), - }; + const doc = toDoc(payload, (existing.data() as EmployeeDoc).avatarUrl ?? null); await ref.set(doc); await audit(auth.companyId, { actorId: auth.employeeId, diff --git a/backend/functions/src/services/invite.ts b/backend/functions/src/services/invite.ts new file mode 100644 index 0000000..735d7e3 --- /dev/null +++ b/backend/functions/src/services/invite.ts @@ -0,0 +1,78 @@ +import { getAuth } from "firebase-admin/auth"; +import { randomBytes } from "crypto"; +import { ApiError, ErrorCodes } from "../lib/errors"; + +/** Roles a manager can assign when onboarding an employee (not COMPANY_ADMIN). */ +export const ASSIGNABLE_ROLES = [ + "EMPLOYEE", + "TEAM_LEAD", + "BRANCH_MANAGER", + "HR_ADMIN", + "PAYROLL_ADMIN", + "AUDITOR", +] as const; + +export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]; + +/** + * Human-shareable temporary password. Email-based invites are a P-next + * enhancement (see docs/12); until then the manager shares this with the new + * employee, who signs into the mobile app with it. + */ +export function generateTempPassword(): string { + // e.g. "Wt-7Kd9Qp2r" — avoids ambiguous chars, always meets the 8-char rule. + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + const bytes = randomBytes(9); + let out = ""; + for (const b of bytes) out += alphabet[b % alphabet.length]; + return `Wt-${out}`; +} + +/** + * Creates the Firebase Auth login for an employee and stamps their tenant/RBAC + * claims. uid == employeeId so GET /me resolves the same document. Returns the + * password that was set (caller shares it). + */ +export async function createEmployeeLogin(params: { + companyId: string; + employeeId: string; + email: string; + displayName: string; + role: AssignableRole; + branchIds: string[]; + password?: string; +}): Promise { + const auth = getAuth(); + + const existing = await auth.getUserByEmail(params.email).catch(() => null); + if (existing) { + throw new ApiError(409, ErrorCodes.CONFLICT, "An account with this email already exists"); + } + + const password = params.password ?? generateTempPassword(); + await auth.createUser({ + uid: params.employeeId, + email: params.email, + password, + displayName: params.displayName, + }); + await auth.setCustomUserClaims(params.employeeId, { + cid: params.companyId, + eid: params.employeeId, + r: [params.role], + b: params.branchIds, + }); + return password; +} + +/** Resets an employee's login password to a fresh temporary one. */ +export async function resetEmployeePassword(employeeId: string): Promise { + const auth = getAuth(); + const user = await auth.getUser(employeeId).catch(() => null); + if (!user) { + throw ApiError.notFound("This employee has no login account"); + } + const password = generateTempPassword(); + await auth.updateUser(employeeId, { password }); + return password; +} diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index a1d8250..7ff765a 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -3,6 +3,7 @@ import { api } from "./client"; import type { AttendanceOverviewRow, Employee, + EmployeeCreated, EmployeeWrite, Kpis, LeaveRequest, @@ -55,7 +56,8 @@ export function useEmployees(params: { cursor?: string; branchId?: string; statu export function useCreateEmployee() { const qc = useQueryClient(); return useMutation({ - mutationFn: (body: EmployeeWrite) => api.post("/employees", body).then((e) => e.data), + mutationFn: (body: EmployeeWrite) => + api.post("/employees", body).then((e) => e.data), onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 19546bb..7f17b30 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -48,6 +48,14 @@ export interface Employee { updatedAt: string; } +export type AssignableRole = + | "EMPLOYEE" + | "TEAM_LEAD" + | "BRANCH_MANAGER" + | "HR_ADMIN" + | "PAYROLL_ADMIN" + | "AUDITOR"; + export interface EmployeeWrite { employeeCode: string; firstName: string; @@ -61,6 +69,14 @@ export interface EmployeeWrite { employmentType: EmploymentType; joinDate: string; status: EmployeeStatus; + role?: AssignableRole; + createLogin?: boolean; + initialPassword?: string; +} + +/** POST /employees echoes the created employee plus the temp login password. */ +export interface EmployeeCreated extends Employee { + tempPassword: string | null; } export interface Branch { diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index bbb1888..8b582d3 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -88,6 +88,22 @@ const fa: Dict = { emp_search: "جستجوی نام یا کود…", emp_load_more: "بارگذاری بیشتر", emp_empty: "کارمندی یافت نشد", + emp_role: "نقش", + emp_create_login: "ساخت حساب ورود برای اپ موبایل", + emp_password_optional: "رمز (خالی = رمز موقت خودکار)", + emp_credentials_title: "حساب کارمند ساخته شد", + emp_credentials_hint: "این معلومات را به کارمند بدهید تا وارد اپ موبایل شود:", + emp_credentials_email: "ایمیل", + emp_credentials_password: "رمز موقت", + emp_credentials_copy: "کپی", + emp_credentials_copied: "کپی شد", + emp_credentials_done: "تمام", + role_employee: "کارمند", + role_team_lead: "سرگروپ", + role_branch_manager: "مدیر شعبه", + role_hr_admin: "مدیر منابع بشری", + role_payroll_admin: "مدیر معاش", + role_auditor: "بازرس", status_active: "فعال", status_on_leave: "در رخصتی", @@ -211,6 +227,22 @@ const ps: Dict = { emp_search: "د نوم یا کوډ لټون…", emp_load_more: "نور بار کړئ", emp_empty: "کارکوونکی ونه موندل شو", + emp_role: "دنده", + emp_create_login: "د موبایل اپ لپاره د ننوتلو حساب جوړ کړئ", + emp_password_optional: "پټنوم (تش = اتومات لنډمهاله پټنوم)", + emp_credentials_title: "د کارکوونکي حساب جوړ شو", + emp_credentials_hint: "دا معلومات کارکوونکي ته ورکړئ چې موبایل اپ ته ننوځي:", + emp_credentials_email: "برېښنالیک", + emp_credentials_password: "لنډمهاله پټنوم", + emp_credentials_copy: "کاپي", + emp_credentials_copied: "کاپي شو", + emp_credentials_done: "پای", + role_employee: "کارکوونکی", + role_team_lead: "د ډلې مشر", + role_branch_manager: "د څانګې مدیر", + role_hr_admin: "د بشري منابعو مدیر", + role_payroll_admin: "د معاش مدیر", + role_auditor: "پلټونکی", status_active: "فعال", status_on_leave: "په رخصتۍ کې", @@ -334,6 +366,22 @@ const en: Dict = { emp_search: "Search name or code…", emp_load_more: "Load more", emp_empty: "No employees found", + emp_role: "Role", + emp_create_login: "Create a mobile-app login", + emp_password_optional: "Password (blank = auto temp password)", + emp_credentials_title: "Employee account created", + emp_credentials_hint: "Share these with the employee so they can sign into the mobile app:", + emp_credentials_email: "Email", + emp_credentials_password: "Temp password", + emp_credentials_copy: "Copy", + emp_credentials_copied: "Copied", + emp_credentials_done: "Done", + role_employee: "Employee", + role_team_lead: "Team lead", + role_branch_manager: "Branch manager", + role_hr_admin: "HR admin", + role_payroll_admin: "Payroll admin", + role_auditor: "Auditor", status_active: "Active", status_on_leave: "On leave", diff --git a/web/src/pages/EmployeesPage.tsx b/web/src/pages/EmployeesPage.tsx index 5314076..9c0db36 100644 --- a/web/src/pages/EmployeesPage.tsx +++ b/web/src/pages/EmployeesPage.tsx @@ -1,12 +1,26 @@ import { useMemo, useState, type FormEvent } from "react"; import { useCreateEmployee, useEmployees } from "../api/hooks"; import { ApiError } from "../api/client"; -import type { Employee, EmployeeStatus, EmploymentType } from "../api/types"; +import type { + AssignableRole, + Employee, + EmployeeCreated, + EmployeeStatus, + EmploymentType, +} from "../api/types"; import { useAuth, useHasPermission } from "../auth/AuthProvider"; import { useI18n } from "../i18n/LocaleProvider"; import { EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; const EMPLOYMENT_TYPES: EmploymentType[] = ["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]; +const ROLES: AssignableRole[] = [ + "EMPLOYEE", + "TEAM_LEAD", + "BRANCH_MANAGER", + "HR_ADMIN", + "PAYROLL_ADMIN", + "AUDITOR", +]; export function EmployeesPage() { const { t, num, shamsi } = useI18n(); @@ -14,6 +28,7 @@ export function EmployeesPage() { const [search, setSearch] = useState(""); const [showForm, setShowForm] = useState(false); const [toast, setToast] = useState(null); + const [credentials, setCredentials] = useState<{ email: string; password: string } | null>(null); const employees = useEmployees({}); @@ -91,23 +106,84 @@ export function EmployeesPage() { {showForm && ( setShowForm(false)} - onCreated={() => { + onCreated={(created) => { setShowForm(false); - setToast(t("emp_created")); - window.setTimeout(() => setToast(null), 2500); + if (created.tempPassword) { + setCredentials({ email: created.email, password: created.tempPassword }); + } else { + setToast(t("emp_created")); + window.setTimeout(() => setToast(null), 2500); + } }} /> )} + {credentials && ( + setCredentials(null)} /> + )} {toast && } ); } -function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { +function CredentialsDialog({ + credentials, + onClose, +}: { + credentials: { email: string; password: string }; + onClose: () => void; +}) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + + function copy() { + void navigator.clipboard + .writeText(`${credentials.email} / ${credentials.password}`) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
e.stopPropagation()} style={{ maxWidth: 420 }}> +

{t("emp_credentials_title")}

+

{t("emp_credentials_hint")}

+
+
+
{t("emp_credentials_email")}
+
{credentials.email}
+
+
+
{t("emp_credentials_password")}
+
{credentials.password}
+
+
+
+ + +
+
+
+ ); +} + +function EmployeeForm({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (created: EmployeeCreated) => void; +}) { const { t } = useI18n(); const { me } = useAuth(); const create = useCreateEmployee(); const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); const [form, setForm] = useState({ employeeCode: "", firstName: "", @@ -118,6 +194,9 @@ function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated: employmentType: "FULL_TIME" as EmploymentType, joinDate: isoToday(), status: "ACTIVE" as EmployeeStatus, + role: "EMPLOYEE" as AssignableRole, + createLogin: true, + initialPassword: "", }); function set(key: K, value: (typeof form)[K]) { @@ -127,8 +206,9 @@ function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated: async function onSubmit(e: FormEvent) { e.preventDefault(); setFieldErrors({}); + setFormError(null); try { - await create.mutateAsync({ + const created = await create.mutateAsync({ employeeCode: form.employeeCode, firstName: form.firstName, lastName: form.lastName, @@ -138,10 +218,21 @@ function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated: employmentType: form.employmentType, joinDate: form.joinDate, status: form.status, + role: form.role, + createLogin: form.createLogin, + initialPassword: form.initialPassword || undefined, }); - onCreated(); + onCreated(created); } catch (err) { - if (err instanceof ApiError) setFieldErrors(err.fieldErrors); + if (err instanceof ApiError) { + setFieldErrors(err.fieldErrors); + // Duplicate-email and other business errors carry no field map. + if (!Object.keys(err.fieldErrors).length) { + setFormError(err.code === "CONFLICT" ? t("signup_email_exists") : err.message); + } + } else { + setFormError(t("common_error")); + } } } @@ -173,10 +264,38 @@ function EmployeeForm({ onClose, onCreated }: { onClose: () => void; onCreated:
- {create.isError && !Object.keys(fieldErrors).length && ( -
{t("common_error")}
+
+ + +
+ + + + {form.createLogin && ( + set("initialPassword", v)} + dir="ltr" + error={fieldErrors.initialPassword} + /> )} + {formError &&
{formError}
} +
+ {canApprove && } + {overview.isLoading ? ( ) : overview.isError ? ( @@ -92,6 +103,96 @@ export function AttendancePage() { ); } +/** Manager review of employee-filed attendance corrections (attendance:approve). */ +function RegularizationApprovals() { + const { t, num, shamsi } = useI18n(); + const pending = usePendingRegularizations(true); + const decide = useDecideRegularization(); + const [toast, setToast] = useState(null); + const [busyId, setBusyId] = useState(null); + + function flash(message: string) { + setToast(message); + window.setTimeout(() => setToast(null), 2500); + } + + async function onDecide(req: Regularization, decision: "APPROVE" | "REJECT") { + let note: string | null = null; + if (decision === "REJECT") { + note = window.prompt(t("reg_reject_prompt")) ?? ""; + if (!note.trim()) return; // rejection requires a note + } + setBusyId(req.id); + try { + await decide.mutateAsync({ id: req.id, decision, note }); + flash(decision === "APPROVE" ? t("reg_approved") : t("reg_rejected")); + } catch { + flash(t("common_error")); + } finally { + setBusyId(null); + } + } + + const rows = pending.data ?? []; + // Hide the whole block when there is nothing to review, so it never adds noise. + if (pending.isLoading || pending.isError || rows.length === 0) return null; + + return ( +
+

+ {t("reg_pending")}{" "} + + {num(rows.length)} + +

+
+ + + + + + + + + + + + {rows.map((req) => ( + + + + + + + + + ))} + +
{t("leave_employee")}{t("reg_date")}{t("reg_requested_in")}{t("reg_requested_out")}{t("reg_reason")} +
{req.employeeName ?? req.employeeId}{shamsi(req.date, { withYear: true })}{req.requestedInAt ? formatTime(req.requestedInAt, num) : "—"}{req.requestedOutAt ? formatTime(req.requestedOutAt, num) : "—"}{req.reason} +
+ + +
+
+
+ {toast && } +
+ ); +} + function formatTime(iso: string, num: (v: string | number) => string): string { const d = new Date(iso); const hh = String(d.getHours()).padStart(2, "0"); From 73af1803fc87fcb9bd2e3605eb6f02be9afc3591 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:55:32 +0000 Subject: [PATCH 023/139] feat(ui): professional UI/UX pass on both the web portal and Android theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web manager portal — a refined, RTL-first design system: - New token system (brand/surface/text/semantic scales) with a full dark theme via prefers-color-scheme; Vazirmatn web font loaded - Split-hero login/signup: teal gradient hero with value props beside a clean auth form (collapses to single column on mobile) - App shell: sticky glassy app bar, sidebar with brand mark, inline SVG nav icons, active-item accent rail, and a user card - Elevated KPI cards with colored accent bars and tabular figures, softer tables (uppercase sticky headers, row hover), dot-prefixed status chips, gradient trend bars, button/input focus rings and micro-motion - i18n keys for the new hero/menu strings across fa/ps/en Android employee app — premium theme tokens (safe, value-only): - Rounded Shapes scale wired into the M3 theme (cards 16dp, etc.) - Layered light/dark surface-container roles + surfaceTint/outlineVariant for a clearer elevation hierarchy Verified: web `tsc -b && vite build` green; login and dashboard rendered via headless Chromium look correct in RTL. Android theme is value/stable-API only (no compile in sandbox) — please report any build errors. --- .../core/designsystem/theme/Color.kt | 12 + .../core/designsystem/theme/Shape.kt | 17 + .../core/designsystem/theme/Theme.kt | 23 +- web/index.html | 7 + web/src/auth/LoginPage.tsx | 62 +- web/src/i18n/strings.ts | 21 + web/src/pages/DashboardPage.tsx | 2 +- web/src/styles.css | 677 +++++++++++++++--- web/src/ui/Layout.tsx | 85 ++- 9 files changed, 750 insertions(+), 156 deletions(-) create mode 100644 core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt index 74bb258..07534aa 100644 --- a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt @@ -19,6 +19,18 @@ val Slate80 = Color(0xFFC2C7CA) val Slate90 = Color(0xFFDEE3E6) val Slate95 = Color(0xFFECF1F4) val Slate99 = Color(0xFFFBFDFE) +val SlateOutline = Color(0xFF6E777C) + +// Layered light surfaces for a subtle elevation hierarchy (cards on background). +val Surface0 = Color(0xFFFFFFFF) +val SurfaceHighLight = Color(0xFFF3F7F9) + +// Layered dark surfaces: near-black base with progressively lighter containers. +val SurfaceDarkLowest = Color(0xFF080D0F) +val SurfaceDark0 = Color(0xFF0E1416) +val SurfaceDark1 = Color(0xFF141B1E) +val SurfaceDark2 = Color(0xFF192124) +val SurfaceDark3 = Color(0xFF1F292C) val Amber10 = Color(0xFF261A00) val Amber20 = Color(0xFF402D00) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt new file mode 100644 index 0000000..daf0a7b --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt @@ -0,0 +1,17 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Slightly rounder than the M3 defaults for a modern, premium feel that matches + * the web portal. Cards land on `medium` (16dp), buttons/chips on `small`/`full`. + */ +val WorkTrackShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp), +) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt index fe9e08e..ab7be32 100644 --- a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt @@ -29,11 +29,18 @@ private val LightColors = lightColorScheme( onErrorContainer = Red10, background = Slate99, onBackground = Slate10, - surface = Slate99, + surface = Surface0, onSurface = Slate10, surfaceVariant = Slate95, onSurfaceVariant = Slate30, - outline = Slate30, + surfaceTint = Teal40, + surfaceContainerLowest = Surface0, + surfaceContainerLow = Slate99, + surfaceContainer = Slate95, + surfaceContainerHigh = SurfaceHighLight, + surfaceContainerHighest = Slate90, + outline = SlateOutline, + outlineVariant = Slate90, ) private val DarkColors = darkColorScheme( @@ -53,13 +60,20 @@ private val DarkColors = darkColorScheme( onError = Red20, errorContainer = Red30, onErrorContainer = Red90, - background = Slate10, + background = SurfaceDark0, onBackground = Slate90, - surface = Slate10, + surface = SurfaceDark0, onSurface = Slate90, surfaceVariant = Slate30, onSurfaceVariant = Slate80, + surfaceTint = Teal80, + surfaceContainerLowest = SurfaceDarkLowest, + surfaceContainerLow = SurfaceDark1, + surfaceContainer = SurfaceDark2, + surfaceContainerHigh = SurfaceDark3, + surfaceContainerHighest = Slate30, outline = Slate80, + outlineVariant = Slate30, ) @Composable @@ -83,6 +97,7 @@ fun WorkTrackTheme( MaterialTheme( colorScheme = colorScheme, typography = WorkTrackTypography, + shapes = WorkTrackShapes, content = content, ) } diff --git a/web/index.html b/web/index.html index 666e446..52e4597 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,14 @@ + WorkTrack — پورتال مدیر + + +
diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx index 9592f38..221468a 100644 --- a/web/src/auth/LoginPage.tsx +++ b/web/src/auth/LoginPage.tsx @@ -51,12 +51,33 @@ export function LoginPage() { const isSignup = mode === "signup"; return ( -
- -
WorkTrack
-
{isSignup ? t("signup_title") : t("tagline")}
+
+ + +
+ +

{isSignup ? t("signup_title") : t("login_welcome")}

+
{isSignup ? t("signup_sub") : t("tagline")}
- {isSignup && ( + {isSignup && ( <>
@@ -106,22 +127,23 @@ export function LoginPage() { setMode(isSignup ? "login" : "signup"); }} > - {isSignup ? t("signup_have_account") : t("signup_no_account")} - + {isSignup ? t("signup_have_account") : t("signup_no_account")} + -
- {LOCALES.map((l) => ( - - ))} -
- +
+ {LOCALES.map((l) => ( + + ))} +
+ +
); } diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index 38bccba..ac52123 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -15,7 +15,9 @@ type Dict = Record; const fa: Dict = { app_title: "پورتال مدیر WorkTrack", tagline: "مدیریت هوشمند نیروی کار برای افغانستان", + brand_tagline: "سامانه منابع بشری", + nav_menu: "منو", nav_dashboard: "داشبورد", nav_employees: "کارمندان", nav_attendance: "حاضری", @@ -49,8 +51,13 @@ const fa: Dict = { login_error: "ایمیل یا رمز عبور نادرست است", login_no_access: "این حساب دسترسی مدیریتی ندارد", login_signing_in: "در حال ورود…", + login_welcome: "خوش آمدید", + auth_point_1: "حاضری با GPS، QR و کیوسک", + auth_point_2: "معاش، رخصتی و اصلاح حاضری", + auth_point_3: "تقویم شمسی و گزارش‌های زنده", signup_title: "ثبت‌نام شرکت", + signup_sub: "فضای کاری شرکت خود را در چند ثانیه بسازید", signup_company: "نام شرکت", signup_admin_first: "نام مدیر", signup_admin_last: "تخلص مدیر", @@ -167,7 +174,9 @@ const fa: Dict = { const ps: Dict = { app_title: "د WorkTrack مدیر پورتال", tagline: "د افغانستان لپاره د کاري ځواک هوښیار مدیریت", + brand_tagline: "د بشري منابعو سیسټم", + nav_menu: "مینو", nav_dashboard: "ډشبورډ", nav_employees: "کارکوونکي", nav_attendance: "حاضري", @@ -201,8 +210,13 @@ const ps: Dict = { login_error: "برېښنالیک یا پټنوم سم نه دی", login_no_access: "دا حساب مدیریتي لاسرسی نه لري", login_signing_in: "ننوتل کېږي…", + login_welcome: "ښه راغلاست", + auth_point_1: "د GPS، QR او کیوسک له لارې حاضري", + auth_point_2: "معاش، رخصتي او د حاضرۍ سمون", + auth_point_3: "لمریز کلیز او ژوندي راپورونه", signup_title: "د شرکت ثبت", + signup_sub: "د خپل شرکت کاري ځای په څو ثانیو کې جوړ کړئ", signup_company: "د شرکت نوم", signup_admin_first: "د مدیر نوم", signup_admin_last: "د مدیر تخلص", @@ -319,7 +333,9 @@ const ps: Dict = { const en: Dict = { app_title: "WorkTrack Manager Portal", tagline: "Smart workforce management for Afghanistan", + brand_tagline: "HR platform", + nav_menu: "Menu", nav_dashboard: "Dashboard", nav_employees: "Employees", nav_attendance: "Attendance", @@ -353,8 +369,13 @@ const en: Dict = { login_error: "Email or password is incorrect", login_no_access: "This account has no manager access", login_signing_in: "Signing in…", + login_welcome: "Welcome back", + auth_point_1: "Attendance via GPS, QR and kiosk", + auth_point_2: "Payroll, leave and attendance corrections", + auth_point_3: "Solar Hijri calendar and live reports", signup_title: "Register your company", + signup_sub: "Spin up your company workspace in seconds", signup_company: "Company name", signup_admin_first: "Admin first name", signup_admin_last: "Admin last name", diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 7d71e2f..5b5da97 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -33,7 +33,7 @@ export function DashboardPage() {
-

{t("dash_trend")}

+

{t("dash_trend")}

{trend.data && trend.data.length > 0 ? ( ({ diff --git a/web/src/styles.css b/web/src/styles.css index 7e6b11e..c4acd1e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,40 +1,95 @@ -/* WorkTrack manager portal — design system. RTL-first via CSS logical - properties (margin-inline, inset-inline) so Dari/Pashto and English share - one stylesheet. Teal brand matches the Android app. */ +/* WorkTrack manager portal — design system. + RTL-first via CSS logical properties (margin-inline, inset-inline) so + Dari/Pashto and English share one stylesheet. Teal brand matches the + Android app. Light + dark themes via prefers-color-scheme. */ :root { - --teal-40: #006874; - --teal-30: #004f58; - --teal-90: #97f0ff; - --teal-container: #cfeef2; - --slate-10: #0f1417; - --slate-30: #3a4043; - --slate-50: #6b7378; - --slate-90: #dee3e6; - --slate-95: #eef2f4; - --slate-99: #fbfdfe; + /* Brand — teal, shared with the Android app */ + --brand: #006874; + --brand-strong: #004f58; + --brand-bright: #0a8394; + --brand-50: #e7f4f6; + --brand-100: #cdeaef; + --on-brand: #ffffff; + + /* Neutrals / surfaces */ + --bg: #f3f6f8; + --bg-tint: radial-gradient(1200px 600px at 100% -10%, #e9f3f5 0%, rgba(233, 243, 245, 0) 55%); --surface: #ffffff; - --bg: #f4f7f8; - --outline: #d3dade; + --surface-2: #f8fafb; + --surface-inset: #f2f5f7; + --border: #e4e9ed; + --border-strong: #d3dbe0; + + /* Text */ + --text: #101a1f; + --text-muted: #5a6b74; + --text-subtle: #869199; - --green: #2e7d32; - --green-bg: #d7efd8; - --amber: #8a6100; - --amber-bg: #ffe8b3; + /* Semantic */ + --green: #1f7a37; + --green-bg: #dcf3e1; + --amber: #8a5a00; + --amber-bg: #ffeccb; --red: #b3261e; - --red-bg: #f9dedc; - --neutral: #49545a; - --neutral-bg: #e1e8ed; + --red-bg: #fbe0dd; + --neutral: #47555d; + --neutral-bg: #e5ebef; - --radius: 12px; - --radius-sm: 8px; - --shadow: 0 1px 3px rgba(15, 20, 23, 0.12), 0 1px 2px rgba(15, 20, 23, 0.06); - --sidebar-width: 244px; + /* Shape */ + --radius-xl: 18px; + --radius: 14px; + --radius-sm: 10px; + --radius-xs: 8px; + /* Elevation */ + --shadow-sm: 0 1px 2px rgba(16, 26, 31, 0.06), 0 1px 3px rgba(16, 26, 31, 0.05); + --shadow: 0 2px 4px rgba(16, 26, 31, 0.05), 0 6px 16px -6px rgba(16, 26, 31, 0.12); + --shadow-lg: 0 12px 32px -8px rgba(16, 26, 31, 0.22), 0 4px 10px -4px rgba(16, 26, 31, 0.12); + --ring: 0 0 0 3px rgba(0, 104, 116, 0.22); + + --sidebar-width: 250px; --font: "Vazirmatn", "Segoe UI", "Noto Naskh Arabic", system-ui, -apple-system, sans-serif; } +@media (prefers-color-scheme: dark) { + :root { + --brand: #4fd8eb; + --brand-strong: #7fe6f4; + --brand-bright: #6fe0f0; + --brand-50: #0e2a30; + --brand-100: #123840; + --on-brand: #00363d; + + --bg: #0c1315; + --bg-tint: radial-gradient(1200px 600px at 100% -10%, #0f2429 0%, rgba(15, 36, 41, 0) 55%); + --surface: #121b1e; + --surface-2: #161f23; + --surface-inset: #1a2529; + --border: #24312f; + --border-strong: #30403f; + + --text: #e5edf0; + --text-muted: #9db0b8; + --text-subtle: #7c8f97; + + --green: #7fd68f; + --green-bg: #17301d; + --amber: #f2c164; + --amber-bg: #35280d; + --red: #ffb4ab; + --red-bg: #3a1512; + --neutral: #b7c4cb; + --neutral-bg: #222f33; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 8px 20px -8px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 16px 40px -10px rgba(0, 0, 0, 0.6); + --ring: 0 0 0 3px rgba(79, 216, 235, 0.28); + } +} + * { box-sizing: border-box; } @@ -49,10 +104,11 @@ body, body { font-family: var(--font); background: var(--bg); - color: var(--slate-10); + color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } a { @@ -65,44 +121,79 @@ button { cursor: pointer; } +/* Numerals align in tables and KPIs */ +.data td, +.kpi .value, +.trend-bar .cap { + font-variant-numeric: tabular-nums; +} + +::-webkit-scrollbar { + width: 11px; + height: 11px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 3px solid var(--bg); +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-subtle); +} + /* ---------- Buttons ---------- */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; - border: none; + border: 1px solid transparent; border-radius: 999px; padding: 10px 22px; font-size: 14px; font-weight: 600; - transition: filter 0.15s ease, background 0.15s ease; + letter-spacing: 0.1px; + transition: transform 0.12s ease, box-shadow 0.15s ease, background 0.15s ease, + border-color 0.15s ease, filter 0.15s ease; } .btn:disabled { opacity: 0.55; cursor: default; } +.btn:not(:disabled):active { + transform: translateY(1px); +} +.btn:focus-visible { + outline: none; + box-shadow: var(--ring); +} .btn-primary { - background: var(--teal-40); - color: #fff; + background: var(--brand); + color: var(--on-brand); + box-shadow: 0 1px 2px rgba(0, 79, 88, 0.24), 0 4px 12px -4px rgba(0, 79, 88, 0.4); } .btn-primary:not(:disabled):hover { - filter: brightness(1.08); + background: var(--brand-bright); + box-shadow: 0 2px 4px rgba(0, 79, 88, 0.24), 0 8px 18px -6px rgba(0, 79, 88, 0.5); } .btn-outline { - background: transparent; - color: var(--teal-40); - border: 1px solid var(--outline); + background: var(--surface); + color: var(--brand); + border-color: var(--border-strong); } .btn-outline:not(:disabled):hover { - background: var(--slate-95); + background: var(--surface-inset); + border-color: var(--brand); } .btn-danger { background: var(--red-bg); color: var(--red); } +.btn-danger:not(:disabled):hover { + filter: brightness(0.97); +} .btn-sm { - padding: 6px 14px; + padding: 7px 15px; font-size: 13px; } @@ -116,23 +207,28 @@ button { .field label { font-size: 13px; font-weight: 600; - color: var(--slate-30); + color: var(--text-muted); } .input, .select { width: 100%; padding: 11px 14px; - border: 1px solid var(--outline); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font-size: 15px; font-family: inherit; background: var(--surface); - color: var(--slate-10); + color: var(--text); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.input::placeholder { + color: var(--text-subtle); } .input:focus, .select:focus { - outline: 2px solid var(--teal-40); - outline-offset: -1px; + outline: none; + border-color: var(--brand); + box-shadow: var(--ring); } .field-error { color: var(--red); @@ -142,20 +238,37 @@ button { /* ---------- Card ---------- */ .card { background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: var(--shadow-sm); padding: 20px; } +.card-title { + margin: 0 0 16px; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.1px; +} /* ---------- Chips ---------- */ .chip { display: inline-flex; align-items: center; - border-radius: var(--radius-sm); - padding: 3px 10px; + gap: 6px; + border-radius: 999px; + padding: 3px 11px; font-size: 12.5px; font-weight: 600; white-space: nowrap; + line-height: 1.6; +} +.chip::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; } .chip-positive { background: var(--green-bg); @@ -182,115 +295,284 @@ button { } .sidebar { background: var(--surface); - border-inline-end: 1px solid var(--outline); - padding: 20px 14px; + border-inline-end: 1px solid var(--border); + padding: 18px 14px; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; + position: sticky; + top: 0; + height: 100vh; } .brand { - font-size: 22px; + display: flex; + align-items: center; + gap: 10px; + font-size: 21px; font-weight: 800; - color: var(--teal-40); - padding: 8px 12px 4px; + letter-spacing: 0.2px; + color: var(--text); + padding: 8px 12px 14px; } .brand small { display: block; font-size: 12px; font-weight: 500; - color: var(--slate-50); + color: var(--text-subtle); +} +.brand-mark { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: linear-gradient(145deg, var(--brand) 0%, var(--brand-strong) 100%); + color: var(--on-brand); + font-size: 17px; + font-weight: 800; + box-shadow: 0 4px 12px -4px rgba(0, 79, 88, 0.5); + flex-shrink: 0; +} +.nav-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-subtle); + padding: 12px 14px 6px; } .nav-item { + position: relative; display: flex; align-items: center; gap: 12px; - padding: 11px 14px; + padding: 10px 14px; border-radius: var(--radius-sm); - color: var(--slate-30); + color: var(--text-muted); font-weight: 600; font-size: 14.5px; + transition: background 0.14s ease, color 0.14s ease; } .nav-item:hover { - background: var(--slate-95); + background: var(--surface-inset); + color: var(--text); } .nav-item.active { - background: var(--teal-container); - color: var(--teal-30); + background: var(--brand-50); + color: var(--brand); +} +.nav-item.active::before { + content: ""; + position: absolute; + inset-inline-start: -14px; + inset-block: 8px; + width: 3px; + border-radius: 999px; + background: var(--brand); } .nav-item .icon { width: 20px; - text-align: center; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.nav-item .icon svg { + width: 19px; + height: 19px; } .sidebar-spacer { flex: 1; } +.user-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--surface-inset); + margin-block-end: 8px; +} +.avatar { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--brand-100); + color: var(--brand-strong); + font-weight: 700; + font-size: 14px; + flex-shrink: 0; +} +.user-card .meta { + min-width: 0; +} +.user-card .meta b { + display: block; + font-size: 13.5px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.user-card .meta span { + font-size: 12px; + color: var(--text-subtle); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + .main { - padding: 24px 28px; - overflow: auto; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--bg); + background-image: var(--bg-tint); +} +.main-inner { + padding: 22px 30px 40px; + animation: rise 0.28s ease; +} +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +} +.appbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 30px; + background: color-mix(in srgb, var(--surface) 82%, transparent); + backdrop-filter: saturate(1.4) blur(10px); + border-block-end: 1px solid var(--border); } .topbar { display: flex; align-items: center; justify-content: space-between; - margin-block-end: 20px; + margin-block-end: 22px; gap: 16px; + flex-wrap: wrap; } .page-title { - font-size: 22px; - font-weight: 700; + font-size: 23px; + font-weight: 800; + letter-spacing: -0.2px; margin: 0; } .topbar-right { display: flex; align-items: center; gap: 12px; + flex-wrap: wrap; } .lang-switch { display: inline-flex; - border: 1px solid var(--outline); + padding: 3px; + gap: 2px; + background: var(--surface-inset); + border: 1px solid var(--border); border-radius: 999px; - overflow: hidden; } .lang-switch button { border: none; background: transparent; - padding: 6px 12px; + padding: 6px 13px; font-size: 13px; font-weight: 600; - color: var(--slate-50); + color: var(--text-muted); + border-radius: 999px; + transition: background 0.14s ease, color 0.14s ease; +} +.lang-switch button:hover { + color: var(--text); } .lang-switch button.active { - background: var(--teal-40); - color: #fff; + background: var(--surface); + color: var(--brand); + box-shadow: var(--shadow-sm); } .user-chip { font-size: 13px; - color: var(--slate-50); + color: var(--text-muted); + font-weight: 500; +} +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 13px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + box-shadow: var(--shadow-sm); } /* ---------- KPI grid ---------- */ .kpi-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 14px; margin-block-end: 22px; } .kpi { + position: relative; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: var(--shadow-sm); padding: 16px 18px; + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.kpi:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} +.kpi::before { + content: ""; + position: absolute; + inset-block-start: 0; + inset-inline: 0; + height: 3px; + background: var(--brand); + opacity: 0.9; +} +.kpi.accent-red::before { + background: var(--red); +} +.kpi.accent-amber::before { + background: var(--amber); } .kpi .value { font-size: 30px; font-weight: 800; line-height: 1.1; - color: var(--teal-40); + letter-spacing: -0.5px; + color: var(--text); } .kpi .label { font-size: 13px; - color: var(--slate-50); - margin-block-start: 4px; + color: var(--text-muted); + margin-block-start: 5px; + font-weight: 500; } .kpi.accent-red .value { color: var(--red); @@ -302,8 +584,9 @@ button { /* ---------- Table ---------- */ .table-wrap { background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: var(--shadow-sm); overflow: auto; } table.data { @@ -314,20 +597,29 @@ table.data { table.data th, table.data td { text-align: start; - padding: 12px 16px; - border-block-end: 1px solid var(--slate-95); + padding: 13px 18px; + border-block-end: 1px solid var(--border); white-space: nowrap; } +table.data tbody tr:last-child td { + border-block-end: none; +} table.data th { - font-size: 12.5px; - color: var(--slate-50); - font-weight: 600; - background: var(--slate-99); + font-size: 12px; + color: var(--text-subtle); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--surface-2); position: sticky; top: 0; + z-index: 1; +} +table.data tbody tr { + transition: background 0.12s ease; } table.data tbody tr:hover { - background: var(--slate-99); + background: var(--surface-2); } .row-actions { display: flex; @@ -341,15 +633,15 @@ table.data tbody tr:hover { align-items: center; justify-content: center; gap: 12px; - padding: 60px 20px; - color: var(--slate-50); + padding: 64px 20px; + color: var(--text-muted); text-align: center; } .spinner { width: 32px; height: 32px; - border: 3px solid var(--slate-90); - border-top-color: var(--teal-40); + border: 3px solid var(--border-strong); + border-top-color: var(--brand); border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -359,7 +651,109 @@ table.data tbody tr:hover { } } -/* ---------- Login ---------- */ +/* ---------- Login / auth ---------- */ +.auth { + min-height: 100%; + display: grid; + grid-template-columns: 1.05fr 1fr; +} +.auth-hero { + position: relative; + overflow: hidden; + background: linear-gradient(155deg, var(--brand-strong) 0%, var(--brand) 55%, var(--brand-bright) 100%); + color: #fff; + padding: 56px 52px; + display: flex; + flex-direction: column; + justify-content: center; +} +.auth-hero::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(520px 320px at 12% 8%, rgba(255, 255, 255, 0.18), transparent 60%), + radial-gradient(480px 300px at 92% 96%, rgba(0, 0, 0, 0.18), transparent 55%); + pointer-events: none; +} +.auth-hero-inner { + position: relative; + max-width: 420px; +} +.auth-brand { + display: flex; + align-items: center; + gap: 12px; + font-size: 30px; + font-weight: 800; + letter-spacing: 0.3px; +} +.auth-brand .brand-mark { + width: 44px; + height: 44px; + border-radius: 13px; + background: rgba(255, 255, 255, 0.16); + backdrop-filter: blur(4px); + box-shadow: none; + font-size: 22px; +} +.auth-hero-tag { + margin-block: 18px 32px; + font-size: 18px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.92); + font-weight: 500; +} +.auth-points { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.auth-points li { + display: flex; + align-items: center; + gap: 12px; + font-size: 15px; + color: rgba(255, 255, 255, 0.95); +} +.auth-points .tick { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + flex-shrink: 0; +} +.auth-main { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 24px; + background: var(--bg); +} +.auth-form { + width: 100%; + max-width: 400px; +} +.auth-form h1 { + font-size: 22px; + font-weight: 800; + margin: 0 0 4px; +} +.auth-form .sub { + color: var(--text-muted); + font-size: 14px; + margin-block-end: 26px; +} +.auth-form .field { + text-align: start; +} + +/* legacy single-card login (kept as fallback) */ .login-page { min-height: 100%; display: flex; @@ -369,49 +763,60 @@ table.data tbody tr:hover { } .login-card { width: 100%; - max-width: 380px; + max-width: 400px; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: var(--shadow-lg); padding: 32px; text-align: center; } -.login-card .brand { - padding: 0; - margin-block-end: 4px; -} -.login-card .tagline { - color: var(--slate-50); +.tagline { + color: var(--text-muted); font-size: 14px; margin-block-end: 24px; } -.login-card .field { - text-align: start; -} /* ---------- Modal ---------- */ .modal-backdrop { position: fixed; inset: 0; - background: rgba(15, 20, 23, 0.45); + background: rgba(8, 16, 20, 0.5); + backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; + animation: fade 0.16s ease; +} +@keyframes fade { + from { + opacity: 0; + } } .modal { width: 100%; - max-width: 520px; + max-width: 540px; max-height: 90vh; overflow: auto; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); - padding: 24px; + box-shadow: var(--shadow-lg); + padding: 26px; + animation: pop 0.18s ease; +} +@keyframes pop { + from { + opacity: 0; + transform: translateY(10px) scale(0.99); + } } .modal h2 { - margin: 0 0 16px; - font-size: 18px; + margin: 0 0 18px; + font-size: 19px; + font-weight: 700; } .form-grid { display: grid; @@ -422,27 +827,37 @@ table.data tbody tr:hover { display: flex; justify-content: flex-end; gap: 10px; - margin-block-start: 12px; + margin-block-start: 14px; } .toast { position: fixed; inset-block-end: 24px; inset-inline-start: 50%; transform: translateX(-50%); - background: var(--slate-10); - color: #fff; - padding: 12px 20px; + background: var(--text); + color: var(--surface); + padding: 12px 22px; border-radius: 999px; font-size: 14px; + font-weight: 500; + box-shadow: var(--shadow-lg); z-index: 60; + animation: toastIn 0.2s ease; +} +@keyframes toastIn { + from { + opacity: 0; + transform: translate(-50%, 10px); + } } -/* ---------- Bars (trend) ---------- */ +/* ---------- Trend chart ---------- */ .trend { + position: relative; display: flex; align-items: flex-end; gap: 10px; - height: 120px; + height: 148px; padding-block-start: 8px; } .trend-bar { @@ -456,31 +871,55 @@ table.data tbody tr:hover { } .trend-bar .bar { width: 100%; - max-width: 40px; - background: var(--teal-container); - border-radius: 6px 6px 0 0; - min-height: 3px; + max-width: 42px; + background: linear-gradient(180deg, var(--brand-100), var(--brand-50)); + border-radius: 7px 7px 3px 3px; + min-height: 4px; + transition: filter 0.15s ease; +} +.trend-bar:hover .bar { + filter: brightness(0.96); } .trend-bar .bar.today { - background: var(--teal-40); + background: linear-gradient(180deg, var(--brand-bright), var(--brand)); } .trend-bar .cap { font-size: 11px; - color: var(--slate-50); + color: var(--text-subtle); + font-weight: 600; } -@media (max-width: 760px) { +/* ---------- Responsive ---------- */ +@media (max-width: 860px) { .shell { grid-template-columns: 1fr; } .sidebar { + position: static; + height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + gap: 6px; border-inline-end: none; - border-block-end: 1px solid var(--outline); + border-block-end: 1px solid var(--border); + } + .sidebar-spacer, + .nav-label, + .user-card { + display: none; + } + .nav-item.active::before { + display: none; + } + .appbar, + .main-inner { + padding-inline: 18px; + } + .auth { + grid-template-columns: 1fr; } - .sidebar-spacer { + .auth-hero { display: none; } .form-grid { diff --git a/web/src/ui/Layout.tsx b/web/src/ui/Layout.tsx index 536777f..af522f1 100644 --- a/web/src/ui/Layout.tsx +++ b/web/src/ui/Layout.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { useAuth, useHasPermission } from "../auth/AuthProvider"; import { useI18n } from "../i18n/LocaleProvider"; @@ -9,20 +10,24 @@ export function Layout() { const can = useHasPermission(); const navItems = [ - { to: "/", icon: "▤", label: t("nav_dashboard"), show: true, end: true }, - { to: "/employees", icon: "◍", label: t("nav_employees"), show: can("employees:read") }, - { to: "/attendance", icon: "◷", label: t("nav_attendance"), show: can("attendance:read") }, - { to: "/leave", icon: "✈", label: t("nav_leave"), show: can("leave:approve") }, - { to: "/payroll", icon: "₼", label: t("nav_payroll"), show: can("payroll:read") }, + { to: "/", icon: , label: t("nav_dashboard"), show: true, end: true }, + { to: "/employees", icon: , label: t("nav_employees"), show: can("employees:read") }, + { to: "/attendance", icon: , label: t("nav_attendance"), show: can("attendance:read") }, + { to: "/leave", icon: , label: t("nav_leave"), show: can("leave:approve") }, + { to: "/payroll", icon: , label: t("nav_payroll"), show: can("payroll:read") }, ]; return (
-
+
{LOCALES.map((l) => (
- +
+ +
); } + +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "؟"; + const first = [...parts[0]][0] ?? ""; + const second = parts.length > 1 ? [...parts[parts.length - 1]][0] ?? "" : ""; + return (first + second).toUpperCase(); +} + +/* ---- Inline icons: stroke inherits currentColor, so they tint with the item ---- */ +function Svg({ children }: { children: ReactNode }) { + return ( + + ); +} +const IconGrid = () => ( + +); +const IconPeople = () => ( + +); +const IconClock = () => ( + +); +const IconPlane = () => ( + +); +const IconWallet = () => ( + +); +const IconLogout = () => ( + +); +const IconBuilding = () => ( + +); From 6e521857902666bc27146e92fb862ad8a29e3c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 04:20:02 +0000 Subject: [PATCH 024/139] feat(web): dark-mode toggle + further UI/UX polish - Theme system: light/dark/system with a persisted preference and a no-FOUC inline resolver in index.html; explicit data-theme overrides win over prefers-color-scheme, with the OS media query kept as a no-JS fallback - ThemeProvider + a sun/moon ThemeToggle button in the app bar and on the login screen; smooth token transitions when switching - Dashboard KPI cards get tinted metric icons; attendance trend gains baseline gridlines - New dot-less .chip-count badge so numeric counts don't read as a digit after the status dot; used by the pending-corrections badge - i18n keys for the theme toggle across fa/ps/en Verified: tsc -b && vite build green; light and dark rendered via headless Chromium (login + dashboard) both look correct in RTL. --- web/index.html | 15 ++++ web/src/auth/LoginPage.tsx | 4 + web/src/i18n/strings.ts | 6 ++ web/src/main.tsx | 25 +++--- web/src/pages/AttendancePage.tsx | 2 +- web/src/pages/DashboardPage.tsx | 59 +++++++++++-- web/src/styles.css | 146 ++++++++++++++++++++++++++++++- web/src/ui/Layout.tsx | 2 + web/src/ui/ThemeProvider.tsx | 106 ++++++++++++++++++++++ 9 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 web/src/ui/ThemeProvider.tsx diff --git a/web/index.html b/web/index.html index 52e4597..651983e 100644 --- a/web/index.html +++ b/web/index.html @@ -14,6 +14,21 @@
+ diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx index 221468a..6016fa1 100644 --- a/web/src/auth/LoginPage.tsx +++ b/web/src/auth/LoginPage.tsx @@ -4,6 +4,7 @@ import { NoManagerAccessError, useAuth } from "./AuthProvider"; import { ApiError, signupCompany } from "../api/client"; import { useI18n } from "../i18n/LocaleProvider"; import { LOCALES } from "../i18n/strings"; +import { ThemeToggle } from "../ui/ThemeProvider"; type Mode = "login" | "signup"; @@ -52,6 +53,9 @@ export function LoginPage() { return (
+
+ +
)} + {/* Allowances and deductions for this person. Only for an employee who + already exists — an assignment needs an id to point at. */} + {canSetPay && isEdit && } + {isEdit ? ( <>
diff --git a/web/src/pages/SalaryComponentsCard.test.tsx b/web/src/pages/SalaryComponentsCard.test.tsx index 92e65af..e9472b5 100644 --- a/web/src/pages/SalaryComponentsCard.test.tsx +++ b/web/src/pages/SalaryComponentsCard.test.tsx @@ -47,6 +47,7 @@ function component(over: Partial = {}): SalaryComponent { calc: "FIXED", value: 2000, taxable: true, + scope: "ALL", active: true, ...over, }; @@ -139,6 +140,7 @@ describe("earnings and deductions", () => { calc: "FIXED", value: 2000, taxable: true, + scope: "ALL", active: true, }, }); diff --git a/web/src/pages/SalaryComponentsCard.tsx b/web/src/pages/SalaryComponentsCard.tsx index 32c32cf..675f733 100644 --- a/web/src/pages/SalaryComponentsCard.tsx +++ b/web/src/pages/SalaryComponentsCard.tsx @@ -40,6 +40,7 @@ const BLANK: SalaryComponentWrite = { calc: "FIXED", value: 0, taxable: true, + scope: "ALL", active: true, }; @@ -200,6 +201,27 @@ export function SalaryComponentsCard() { ))} + +
+ + + + {t("comp_scope_hint")} + +
@@ -292,6 +314,7 @@ export function SalaryComponentsCard() { {t("comp_name")} {t("comp_amount")} + {t("comp_scope")} {ty === "EARNING" && {t("comp_taxable")}} {t("comp_status")} {canManage && } @@ -310,6 +333,13 @@ export function SalaryComponentsCard() { then unit — and forcing LTR here reordered it on screen to "افغانی 500". */} {amountOf(c)} + + + {c.scope === "INDIVIDUAL" + ? t("comp_scope_individual") + : t("comp_scope_all")} + + {ty === "EARNING" && ( From e0d575463c2d51c0583bd7864113c80811cc773b Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 20:52:27 -0400 Subject: [PATCH 070/139] chore(site): brochure updater, ready to run once wp-admin is signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three edits per language: payroll now says allowances and deductions can be set for one person; the licensing note stops claiming nobody has to call us to change a seat count, which stopped being true when licences became vendor-issued; and a download button sits beside the demo button. It reads the RAW block content over the authenticated API rather than the rendered HTML an anonymous request returns — posting rendered HTML back would flatten the page's Gutenberg blocks. It dry-runs by default, asserts each anchor appears exactly once, checks the structural class names survive and the size change is plausible, and skips a whole page rather than half-editing it. Every anchor verified unique against all three live pages. Co-Authored-By: Claude Opus 5 --- scripts/update-brochure.js | 144 +++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 scripts/update-brochure.js diff --git a/scripts/update-brochure.js b/scripts/update-brochure.js new file mode 100644 index 0000000..7e82237 --- /dev/null +++ b/scripts/update-brochure.js @@ -0,0 +1,144 @@ +/* + * Updates the WorkTrack brochure on linumic.com — all three languages. + * + * Run it from a browser tab that is signed in to linumic.com/wp-admin: it needs + * the authenticated REST API to read the RAW block content. The rendered HTML + * that an anonymous request returns is not the same thing; posting that back + * would flatten the page's Gutenberg blocks and leave it uneditable. + * + * Dry run by default: it reports what it would change and writes nothing. + * Pass true to actually save. + * + * await updateBrochure(false) // show the diff + * await updateBrochure(true) // save + * + * Every replacement asserts the old text appears EXACTLY once. A page whose + * wording has drifted is skipped and reported rather than half-edited — the + * failure mode to avoid is a page that is neither the old version nor the new. + */ + +const EDITS = { + 2054: { + lang: "English", + replacements: [ + [ + "Monthly runs over Solar Hijri periods. Basic pay, allowances, deductions and unpaid absence, with income tax withheld on the statutory monthly brackets — and payslips staff open on their phones.", + "Monthly runs over Solar Hijri periods. Basic pay, allowances and deductions — set for the whole company, or a different figure for one person — unpaid absence, and income tax withheld on the statutory monthly brackets. Payslips staff open on their phones.", + ], + [ + "Nobody has to call us to change a number.", + "Freeing a seat is yours to do. How many seats you have is part of your licence, which we issue — tell us and we change it.", + ], + [ + 'Try the demo', + 'Try the demoDownload the app', + ], + ], + }, + 2055: { + lang: "Dari", + replacements: [ + [ + "اجرای ماهانه روی دوره‌های هجری شمسی. معاش اساسی، مزایا، کسورات و غیرحاضری بدون معاش، با مالیهٔ معاش روی جدول قانونی ماهانه — و فیش‌هایی که کارمند روی گوشی خودش باز می‌کند.", + "اجرای ماهانه روی دوره‌های هجری شمسی. معاش اساسی، مزایا و کسورات — برای همهٔ شرکت، یا مبلغی جداگانه برای یک نفر — غیرحاضری بدون معاش، و مالیهٔ معاش روی جدول قانونی ماهانه. فیش‌هایی که کارمند روی گوشی خودش باز می‌کند.", + ], + [ + "کسی لازم نیست برای عوض کردن یک عدد به ما زنگ بزند.", + "آزاد کردن یک صندلی کار خود شماست. تعداد صندلی‌ها بخشی از لایسنس شماست که ما صادر می‌کنیم — به ما بگویید تا تغییرش دهیم.", + ], + [ + 'دمو را امتحان کنید', + 'دمو را امتحان کنیددانلود اپلیکیشن', + ], + ], + }, + 2056: { + lang: "Pashto", + replacements: [ + [ + "میاشتنۍ اجرا د لمریز هجري دورو پر بنسټ. اساسي معاش، امتیازات، کسرونه او بې‌معاشه غیرحاضري، د معاش مالیه د قانوني میاشتني جدول له مخې — او هغه فیشونه چې کارکوونکی یې پر خپل موبایل پرانیزي.", + "میاشتنۍ اجرا د لمریز هجري دورو پر بنسټ. اساسي معاش، امتیازات او کسرونه — د ټول شرکت لپاره، یا د یو تن لپاره بېل مبلغ — بې‌معاشه غیرحاضري، او د معاش مالیه د قانوني میاشتني جدول له مخې. هغه فیشونه چې کارکوونکی یې پر خپل موبایل پرانیزي.", + ], + [ + "هیچا ته اړتیا نشته چې د یو عدد بدلولو لپاره موږ ته زنګ ووهي.", + "د یوه ځای ازادول ستاسو خپل کار دی. د ځایونو شمېر ستاسو د جواز برخه ده چې موږ یې ورکوو — موږ ته ووایاست، بدلوو یې.", + ], + [ + 'ډیمو وازمایئ', + 'ډیمو وازمایئاپلیکیشن ډاونلوډ کړئ', + ], + ], + }, +}; + +/** Landmarks that must survive every edit, or the page has been damaged. */ +const MUST_SURVIVE = ["lnm-section", "lnm-tier", "lnm-feat", "lnm-sechead-h"]; + +async function updateBrochure(apply = false) { + const nonce = window.wpApiSettings?.nonce ?? null; + const report = []; + + for (const [id, spec] of Object.entries(EDITS)) { + const res = await fetch(`/wp-json/wp/v2/pages/${id}?context=edit&_fields=content,title`, { + credentials: "include", + headers: nonce ? { "X-WP-Nonce": nonce } : {}, + }); + if (!res.ok) { + report.push({ id, lang: spec.lang, status: `cannot read (${res.status}) — sign in to wp-admin` }); + continue; + } + + const page = await res.json(); + const before = page.content.raw; + let after = before; + const applied = []; + const missing = []; + + for (const [oldText, newText] of spec.replacements) { + const count = after.split(oldText).length - 1; + if (count !== 1) { + missing.push({ count, snippet: oldText.slice(0, 60) }); + continue; + } + after = after.replace(oldText, newText); + applied.push(oldText.slice(0, 48)); + } + + if (missing.length) { + // Half-editing a live page is worse than not editing it. + report.push({ id, lang: spec.lang, status: "SKIPPED — wording drifted", missing }); + continue; + } + + const lost = MUST_SURVIVE.filter((k) => after.split(k).length < before.split(k).length); + if (lost.length) { + report.push({ id, lang: spec.lang, status: "SKIPPED — edit would remove structure", lost }); + continue; + } + + const delta = after.length - before.length; + if (delta < 0 || delta > 2000) { + report.push({ id, lang: spec.lang, status: `SKIPPED — implausible size change (${delta})` }); + continue; + } + + if (!apply) { + report.push({ id, lang: spec.lang, status: `would change (${applied.length} edits, +${delta} bytes)` }); + continue; + } + + const save = await fetch(`/wp-json/wp/v2/pages/${id}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...(nonce ? { "X-WP-Nonce": nonce } : {}) }, + body: JSON.stringify({ content: after }), + }); + report.push({ + id, + lang: spec.lang, + status: save.ok ? `SAVED (+${delta} bytes)` : `save failed (${save.status})`, + }); + } + + return report; +} From d490918af48de34bf01d3f90f015cbe12a2dd6ed Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 21:09:51 -0400 Subject: [PATCH 071/139] chore(site): record the brochure wording that actually shipped The licence sentence went out as two sentences; the first duplicated the one before it, so it was removed in a follow-up edit. This file now carries the published wording rather than the superseded draft, so re-running it against a restored revision would reproduce what is live. Co-Authored-By: Claude Opus 5 --- scripts/update-brochure.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/update-brochure.js b/scripts/update-brochure.js index 7e82237..02f3390 100644 --- a/scripts/update-brochure.js +++ b/scripts/update-brochure.js @@ -12,6 +12,10 @@ * await updateBrochure(false) // show the diff * await updateBrochure(true) // save * + * The licence sentence here is the wording actually published: an earlier draft + * opened with "Freeing a seat is yours to do", which duplicated the sentence + * before it and was dropped. + * * Every replacement asserts the old text appears EXACTLY once. A page whose * wording has drifted is skipped and reported rather than half-edited — the * failure mode to avoid is a page that is neither the old version nor the new. @@ -27,7 +31,7 @@ const EDITS = { ], [ "Nobody has to call us to change a number.", - "Freeing a seat is yours to do. How many seats you have is part of your licence, which we issue — tell us and we change it.", + "How many seats you have is part of your licence, which we issue — tell us and we change it.", ], [ 'Try the demo', @@ -44,7 +48,7 @@ const EDITS = { ], [ "کسی لازم نیست برای عوض کردن یک عدد به ما زنگ بزند.", - "آزاد کردن یک صندلی کار خود شماست. تعداد صندلی‌ها بخشی از لایسنس شماست که ما صادر می‌کنیم — به ما بگویید تا تغییرش دهیم.", + "تعداد صندلی‌ها بخشی از لایسنس شماست که ما صادر می‌کنیم — به ما بگویید تا تغییرش دهیم.", ], [ 'دمو را امتحان کنید', @@ -61,7 +65,7 @@ const EDITS = { ], [ "هیچا ته اړتیا نشته چې د یو عدد بدلولو لپاره موږ ته زنګ ووهي.", - "د یوه ځای ازادول ستاسو خپل کار دی. د ځایونو شمېر ستاسو د جواز برخه ده چې موږ یې ورکوو — موږ ته ووایاست، بدلوو یې.", + "د ځایونو شمېر ستاسو د جواز برخه ده چې موږ یې ورکوو — موږ ته ووایاست، بدلوو یې.", ], [ 'ډیمو وازمایئ', From f21012fa3bd0c20e83f72e5229f85de569aaf6c2 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 21:23:30 -0400 Subject: [PATCH 072/139] fix(demo): restore the APK downloads I broke, and show the new feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo page's two download links had been returning the SPA's index.html — 1546 bytes of HTML with an .apk name. I did that today: the demo hosting is rebuilt from scratch by `vite build --outDir dist-demo` on every portal deploy, and my two demo deploys wiped the APKs that were sitting there. So anyone who clicked "Download for Android" on linumic.com got a web page. stage-downloads.sh now takes a target. `prod` behaves as before; `demo` copies the signed demo build into web/dist-demo under the exact filenames the live page already links to, so the page needs no edit and cannot drift from the files again. Both targets verified. The demo APK is now 1.0.1-demo (app.worktrack.demo, same signing key), which matters more than the version number suggests: the 1.0.0 build it replaces could never obtain location permission on Android 12 or newer, so a prospect trying the demo on a modern phone would have found check-in simply broken. The seed now carries the three shapes of per-employee component, so the demo demonstrates the feature instead of describing it: Yusuf has a site bonus nobody else gets, Ahmad is on 4500 transport where the company pays 3000, and Omar is withheld from transport. Confirmed by running payroll against the seeded tenant. demoTenantReset runs this same seed nightly, so it stays true. The demo pages on linumic.com say so, in all three languages. Guarded on the things a careless edit destroyed here once before: 14 wt-cell blocks, the sandbox warning, and both APK links — all still present after the save. Co-Authored-By: Claude Opus 5 --- backend/functions/seed.js | 21 +++++++++++ scripts/stage-downloads.sh | 77 +++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/backend/functions/seed.js b/backend/functions/seed.js index c230d65..f057ca0 100644 --- a/backend/functions/seed.js +++ b/backend/functions/seed.js @@ -264,6 +264,21 @@ const salaryComponents = [ { id: "sc_transport", name: "کمک‌هزینه ترانسپورت", code: "TRANSPORT", type: "EARNING", calc: "FIXED", value: 3000, taxable: false, active: true }, { id: "sc_food", name: "کمک‌هزینه غذا", code: "FOOD", type: "EARNING", calc: "FIXED", value: 2000, taxable: false, active: true }, { id: "sc_pension", name: "سهم کارفرما (تقاعد)", code: "PENSION_ER", type: "EMPLOYER_COST", calc: "PERCENT_OF_BASIC", value: 5, taxable: false, active: true }, + // Individual-only, so the demo shows a component that reaches one person + // rather than the whole company. + { id: "sc_site", name: "امتیاز ساحه", code: "SITE", type: "EARNING", calc: "FIXED", value: 4000, taxable: true, scope: "INDIVIDUAL", active: true }, +]; + +/** + * Per-employee exceptions, so a visitor opening an employee sees the three + * shapes this supports rather than having to imagine them: one person on the + * site bonus nobody else gets, one on a larger transport allowance, and one + * withheld from transport altogether. + */ +const employeeComponents = [ + { employeeId: "emp_yusuf", componentId: "sc_site", value: null, active: true }, + { employeeId: "emp_ahmad", componentId: "sc_transport", value: 4500, active: true }, + { employeeId: "emp_omar", componentId: "sc_transport", value: null, active: false }, ]; // Per-employee monthly basic salary (AFN). The manager (emp_admin) earns more. @@ -614,6 +629,12 @@ async function seedPayroll() { } async function seedExtras() { + for (const a of employeeComponents) { + await col("employeeComponents") + .doc(`${a.employeeId}__${a.componentId}`) + .set({ companyId: CID, ...a, updatedAt: now }); + } + for (const c of salaryComponents) { await col("salaryComponents").doc(c.id).set({ companyId: CID, ...c, updatedAt: now }); } diff --git a/scripts/stage-downloads.sh b/scripts/stage-downloads.sh index 037e417..8b82fc0 100755 --- a/scripts/stage-downloads.sh +++ b/scripts/stage-downloads.sh @@ -22,20 +22,43 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# Gradle owns this directory and clears stale outputs from it, so read the +TARGET="${1:-prod}" + +# Gradle owns these directories and clears stale outputs from them, so read the # APKs where the build actually leaves them rather than from a hand-kept copy. -SRC="$ROOT/app/build/outputs/apk/release" -DEST="$ROOT/web/dist/app" +case "$TARGET" in + prod) + SRC="$ROOT/app/build/outputs/apk/release" + WEB="$ROOT/web/dist" + DEST="$WEB/app" + GRADLE_TASK=":app:assembleRelease" + WEB_TASK="npm --prefix web run build" + ;; + demo) + # The demo hosting is rebuilt from scratch on every portal deploy, so the + # APKs have to be re-staged each time or the download links on + # linumic.com/…/demo/ start returning the SPA's index.html. + SRC="$ROOT/app/build/outputs/apk/demo" + WEB="$ROOT/web/dist-demo" + DEST="$WEB" + GRADLE_TASK=":app:assembleDemo" + WEB_TASK="npm --prefix web run build -- --mode demo --outDir dist-demo" + ;; + *) + echo "Usage: $0 [prod|demo]" >&2 + exit 1 + ;; +esac if [ ! -d "$SRC" ]; then - echo "No $SRC — build the signed release APKs first:" >&2 - echo " ./gradlew :app:assembleRelease" >&2 + echo "No $SRC — build the signed APKs first:" >&2 + echo " ./gradlew $GRADLE_TASK" >&2 exit 1 fi -if [ ! -d "$ROOT/web/dist" ]; then - echo "No web/dist — build the portal first:" >&2 - echo " npm --prefix web run build" >&2 +if [ ! -d "$WEB" ]; then + echo "No $WEB — build the portal first:" >&2 + echo " $WEB_TASK" >&2 exit 1 fi @@ -49,28 +72,48 @@ print(m["elements"][0]["versionName"]) PY )" -echo "Staging WorkTrack $VERSION" -rm -rf "$DEST" +echo "Staging WorkTrack $VERSION ($TARGET)" +if [ "$TARGET" = "prod" ]; then + rm -rf "$DEST" +fi mkdir -p "$DEST" -# x86_64 is emulator-only; shipping it to customers just adds 33 MB of confusion. -declare -a NAMES=( - "app-arm64-v8a-release.apk:worktrack-$VERSION-arm64.apk" - "app-armeabi-v7a-release.apk:worktrack-$VERSION-arm32.apk" - "app-universal-release.apk:worktrack-$VERSION-universal.apk" -) +# x86_64 is emulator-only; shipping it to anyone just adds 33 MB of confusion. +# +# The demo filenames are fixed rather than versioned: linumic.com's demo page +# links to them by name, so a version in the filename would break those links +# on every release. +if [ "$TARGET" = "prod" ]; then + declare -a NAMES=( + "app-arm64-v8a-release.apk:worktrack-$VERSION-arm64.apk" + "app-armeabi-v7a-release.apk:worktrack-$VERSION-arm32.apk" + "app-universal-release.apk:worktrack-$VERSION-universal.apk" + ) +else + declare -a NAMES=( + "app-arm64-v8a-demo.apk:worktrack-demo.apk" + "app-armeabi-v7a-demo.apk:worktrack-demo-older-phones.apk" + ) +fi for pair in "${NAMES[@]}"; do from="${pair%%:*}" to="${pair##*:}" if [ ! -f "$SRC/$from" ]; then - echo " missing $from — did the release build run?" >&2 + echo " missing $from — did $GRADLE_TASK run?" >&2 exit 1 fi cp "$SRC/$from" "$DEST/$to" echo " $to ($(du -h "$DEST/$to" | cut -f1))" done +if [ "$TARGET" = "demo" ]; then + echo + echo "Staged into web/dist-demo — deploy the demo hosting to publish:" + echo " npx firebase deploy --only hosting --project worktrack-demo-af --config firebase.demo.json" + exit 0 +fi + # Customers are told to check this before installing, so it has to be generated # from the files actually being published, not typed by hand. ( cd "$DEST" && shasum -a 256 ./*.apk | sed 's|\./||' > SHA256SUMS.txt ) From fa4f059a9c53e3e5d1a6fc64d1dae7a6b55737a1 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 21:33:12 -0400 Subject: [PATCH 073/139] fix(demo): an individual deduction, and stop the demo breaking when used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the deduction side of per-employee components: فاطمه repays a company loan nobody else has. With the site bonus, the raised transport allowance and the withheld transport already there, the demo now shows all four shapes rather than only the earnings ones. Testing that turned up something worse, and mine. The seed lays down attendance and separately hand-writes the payslips, and it only wrote seven days. Payroll now counts a working day with no attendance record as unexcused absence, so pressing "Run payroll" — the obvious first click on the page the demo page advertises as "a finished payroll run" — recomputed the month and docked everyone for the days the seed never wrote. Measured before the fix: Maryam's net fell from 26,300 to 19,900 and nine days of absence appeared. Before this week's calendar change a re-run counted only the days that existed, so this did not happen; my change made the demo degrade on contact. Attendance now covers 40 days, which clears the longest Shamsi month, so a re-run reproduces the seeded picture: net 26,800 against 26,300 seeded, zero absence days, correctly flagged provisional because the month is still running. The variety — a late arrival, a half day, an absence, a leave day — stays in the last week where a visitor will actually look. Co-Authored-By: Claude Opus 5 --- backend/functions/seed.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/functions/seed.js b/backend/functions/seed.js index f057ca0..ad056cf 100644 --- a/backend/functions/seed.js +++ b/backend/functions/seed.js @@ -267,6 +267,9 @@ const salaryComponents = [ // Individual-only, so the demo shows a component that reaches one person // rather than the whole company. { id: "sc_site", name: "امتیاز ساحه", code: "SITE", type: "EARNING", calc: "FIXED", value: 4000, taxable: true, scope: "INDIVIDUAL", active: true }, + // A deduction that reaches one person, so the demo shows both sides of the + // payslip being set individually rather than only the earnings side. + { id: "sc_loan", name: "قسط قرضه", code: "LOAN", type: "DEDUCTION", calc: "FIXED", value: 2500, taxable: false, scope: "INDIVIDUAL", active: true }, ]; /** @@ -279,6 +282,8 @@ const employeeComponents = [ { employeeId: "emp_yusuf", componentId: "sc_site", value: null, active: true }, { employeeId: "emp_ahmad", componentId: "sc_transport", value: 4500, active: true }, { employeeId: "emp_omar", componentId: "sc_transport", value: null, active: false }, + // فاطمه is repaying a company loan; nobody else has this line. + { employeeId: "emp_fatima", componentId: "sc_loan", value: null, active: true }, ]; // Per-employee monthly basic salary (AFN). The manager (emp_admin) earns more. @@ -391,9 +396,21 @@ async function seedAuth() { } } +/** + * How far back attendance is laid down. + * + * It has to cover the whole elapsed part of the current Shamsi month, not just + * the last week. Payroll counts a working day with no attendance record as + * unexcused absence, so a visitor who presses "Run payroll" on the seeded + * month would otherwise watch everybody's pay drop by the days the seed never + * wrote — the demo's headline artifact falling apart on the first click. + * 40 days clears the longest Shamsi month with room to spare. + */ +const ATTENDANCE_DAYS = 40; + async function seedAttendance() { - // 7 days of attendance for every employee, with realistic variety. - for (let d = 6; d >= 0; d--) { + // Attendance for every employee, with realistic variety in the last week. + for (let d = ATTENDANCE_DAYS - 1; d >= 0; d--) { const iso = isoDaysAgo(d); const weekday = new Date(`${iso}T00:00:00Z`).getUTCDay(); // 5 = Friday employees.forEach((e, idx) => { From e5aee12e1642887238d871f083e8ad8bdc3b98a5 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 21:57:48 -0400 Subject: [PATCH 074/139] refactor(demo): seed the payroll run with the real engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedPayroll hand-wrote the payslips, the run document and a journal entry, alongside its own copy of the Afghan tax brackets. It has now drifted twice in one day: the seeded payslips knew nothing about per-employee allowances, and this week's absence rule meant pressing "Run payroll" in the demo produced different numbers from the ones on screen. It now calls computePayrollRun, so what a visitor first sees is what the product produces — verified equal field by field, including every payslip line. The duplicated tax function is gone; a second copy of that arithmetic could only ever drift. An adversarial review of the change found four things worth fixing, three of them mine and all invisible to a local run: The seed wrote UTC day keys while payroll counts days in the company's timezone. The nightly reset fires at 03:30 Asia/Kabul — 23:00 UTC the previous day — so for that window the seed's last attendance day was one behind payroll's today, and every demo payslip picked up a phantom absence. Day keys now come from an Asia/Kabul formatter. My local run passed because I ran it at midday, when the two calendars agree. seedAttendance fired ~320 writes without awaiting any of them. That was survivable while nothing read them in the same run; payroll now does, so it raced the data it depends on, and a rejected write would have surfaced as an unhandled rejection instead of failing the reset. They are collected and awaited per day. Payroll dated its ledger accrual at the period end. For a completed month that is right, and it stays. For a month still running it put the entire salary cost on a date that has not arrived — the ledger, and every trend drawn from it, showing an expense in the future. An in-progress run is now recognised on the day it was made. This one is not demo-only: it affects any customer who runs a provisional month. Two tests pin both halves; with the old line back, the in-progress one fails. Smaller: the seed's chart of accounts omitted the two liability codes the engine credits, so it no longer mirrored DEFAULT_ACCOUNTS — now identical, all seventeen. requirePayroll claimed "not built" for any error the module threw, including its own; it now says that only for a genuine resolution failure and rethrows everything else with its stack. `npm run seed` builds first, so it cannot fail at the last step having already written the whole tenant. Co-Authored-By: Claude Opus 5 --- backend/functions/package.json | 2 +- backend/functions/seed.js | 164 +++++++----------- .../src/services/payroll.integration.test.ts | 27 +++ backend/functions/src/services/payroll.ts | 7 +- 4 files changed, 101 insertions(+), 99 deletions(-) diff --git a/backend/functions/package.json b/backend/functions/package.json index 2e7eb48..a0bf4aa 100644 --- a/backend/functions/package.json +++ b/backend/functions/package.json @@ -11,7 +11,7 @@ "build": "tsc", "watch": "tsc --watch", "serve": "npm run build && firebase emulators:start --config ../../firebase.json --project demo-worktrack --only functions,firestore,auth", - "seed": "node seed.js", + "seed": "npm run build && node seed.js", "deploy": "firebase deploy --only functions", "typecheck": "tsc --noEmit", "test:integration": "firebase emulators:exec --only firestore,auth --project demo-worktrack \"vitest run\"", diff --git a/backend/functions/seed.js b/backend/functions/seed.js index ad056cf..39f57a0 100644 --- a/backend/functions/seed.js +++ b/backend/functions/seed.js @@ -102,8 +102,25 @@ function col(collection) { } /** ISO date (YYYY-MM-DD, UTC) N days before today; 0 = today. */ +/** + * A day key in the company's own timezone. + * + * Not UTC. The nightly reset fires at 03:30 Asia/Kabul, which is 23:00 UTC the + * previous day, so a UTC day key is one behind what payroll calls today — and + * payroll charges a working day with no attendance record as unexcused + * absence. Seeding in UTC put a phantom absence on every demo payslip for the + * four and a half hours a day the two calendars disagree. + */ +const SEED_TZ = "Asia/Kabul"; +const dayFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: SEED_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + function isoDaysAgo(n) { - return new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10); + return dayFormatter.format(new Date(Date.now() - n * 86_400_000)); } /** Timestamp at HH:mm UTC on an ISO date. */ @@ -134,14 +151,6 @@ function gregToShamsi(date) { const TODAY = isoDaysAgo(0); const YEAR = Number(TODAY.slice(0, 4)); -/** Afghan progressive monthly wage tax — mirrors services/payroll.ts. */ -function afghanTax(taxable) { - if (taxable <= 5000) return 0; - if (taxable <= 12500) return Math.round((taxable - 5000) * 0.02); - if (taxable <= 100000) return Math.round(150 + (taxable - 12500) * 0.1); - return Math.round(8900 + (taxable - 100000) * 0.2); -} - /** Placeholder check-in "selfie" avatars (SVG data URLs) for the demo overview. * Real captures come from the employee app's camera; these just demo the UI. */ const SELFIE_AVATARS = ["#0a8394", "#2e7d32", "#8a5a00"].map( @@ -413,6 +422,10 @@ async function seedAttendance() { for (let d = ATTENDANCE_DAYS - 1; d >= 0; d--) { const iso = isoDaysAgo(d); const weekday = new Date(`${iso}T00:00:00Z`).getUTCDay(); // 5 = Friday + // Collected and awaited below: payroll reads this collection immediately + // after, so firing these off unawaited raced the run — and a rejected write + // would have surfaced as an unhandled rejection rather than failing here. + const writes = []; employees.forEach((e, idx) => { let status = "PRESENT"; let lateMinutes = 0; @@ -450,7 +463,7 @@ async function seedAttendance() { // days, so the manager's overview visibly shows photo-verified attendance. const hasSelfie = d === 0 && firstInAt && idx < 3; - col("attendanceDays").doc(`${e.id}_${iso}`).set({ + writes.push(col("attendanceDays").doc(`${e.id}_${iso}`).set({ employeeId: e.id, date: iso, shiftId: null, @@ -464,8 +477,10 @@ async function seedAttendance() { checkInSelfie: hasSelfie ? SELFIE_AVATARS[idx % SELFIE_AVATARS.length] : null, computedAt: now, updatedAt: now, - }); + })); }); + + await Promise.all(writes); } } @@ -556,93 +571,42 @@ async function seedPayroll() { }); } - // Pre-generate a finalized payroll run for the current Solar Hijri month so - // payslips are visible immediately in the portal and the employee app. The - // shape matches services/payroll.ts so a re-run from the portal overwrites it. + // The finished payroll run the demo advertises is produced by the real + // engine, not written by hand here. + // + // It used to be hand-written, and the two drifted: the seeded payslips knew + // nothing about per-employee allowances, and pressing "Run payroll" in the + // demo replaced them with different numbers. Calling computePayrollRun means + // what a visitor first sees is exactly what the product produces — including + // the ledger accrual, which this function posts itself, so the hand-written + // journal entry is gone with it. + const { computePayrollRun } = requirePayroll(); const sh = gregToShamsi(new Date()); - const runId = `${sh.year}_${String(sh.month).padStart(2, "0")}`; - let totalGross = 0; - let totalNet = 0; - let totalTax = 0; - let totalEmployerCost = 0; - let count = 0; - for (const e of employees) { - const basic = employeeSalaries[e.id]; - if (!basic) continue; - const gross = basic + 3000 + 2000; - const tax = afghanTax(gross); - const employerCost = Math.round(basic * 0.05); // 5% employer pension - const lines = [ - { componentCode: "BASIC", componentName: "معاش اساسی", type: "EARNING", amount: basic }, - { componentCode: "TRANSPORT", componentName: "کمک‌هزینه ترانسپورت", type: "EARNING", amount: 3000 }, - { componentCode: "FOOD", componentName: "کمک‌هزینه غذا", type: "EARNING", amount: 2000 }, - { componentCode: "TAX", componentName: "مالیهٔ معاش", type: "DEDUCTION", amount: tax }, - { componentCode: "PENSION_ER", componentName: "سهم کارفرما (تقاعد)", type: "EMPLOYER_COST", amount: employerCost }, - ]; - const net = gross - tax; - await col("payslips").doc(`${e.id}_${runId}`).set({ - companyId: CID, - runId, - employeeId: e.id, - periodYear: sh.year, - periodMonth: sh.month, - currency: "AFN", - gross, - totalDeductions: tax, - net, - incomeTax: tax, - employerCost, - costToCompany: gross + employerCost, - workedDays: 22, - paidLeaveDays: 0, - lopDays: 0, - overtimeMinutes: 0, - status: "FINALIZED", - pdfUrl: null, - lines, - updatedAt: now, - }); - totalGross += gross; - totalNet += net; - totalTax += tax; - totalEmployerCost += employerCost; - count += 1; - } - await col("payrollRuns").doc(runId).set({ - companyId: CID, - periodYear: sh.year, - periodMonth: sh.month, - status: "APPROVED", - startedBy: "emp_admin", - approvedBy: "emp_admin", - currency: "AFN", - payslipCount: count, - totalGross, - totalNet, - totalTax, - totalEmployerCost, - lockedAt: now, - createdAt: now, - updatedAt: now, - }); + await computePayrollRun(CID, sh.year, sh.month, "emp_admin", "AFN"); +} - // Accrue this run to the general ledger so the finance module shows salary - // cost immediately (Dr Salaries & Wages; Cr Salaries Payable + Taxes Payable). - const payGross = totalGross + totalEmployerCost; - await col("journalEntries").doc(`je_payroll_${runId}`).set({ - date: TODAY, - memo: `Payroll ${sh.year}/${String(sh.month).padStart(2, "0")}`, - reference: runId, - source: "PAYROLL", - lines: [ - { accountCode: "5000", accountName: "Salaries & Wages", debit: payGross, credit: 0 }, - { accountCode: "2100", accountName: "Salaries Payable", debit: 0, credit: payGross - totalTax }, - { accountCode: "2200", accountName: "Taxes Payable", debit: 0, credit: totalTax }, - ], - totalDebit: payGross, - createdBy: "emp_admin", - createdAt: now, - }); +/** + * The compiled payroll service. + * + * seed.js is plain CommonJS and the service is TypeScript, so this reaches for + * the build output. Failing here with an explanation beats a bare MODULE_NOT_FOUND + * from inside a nightly reset. + */ +function requirePayroll() { + try { + return require("./lib/services/payroll"); + } catch (err) { + // Only a resolution failure means "not built". Anything else — a throw from + // inside the module itself — must reach the log with its own stack rather + // than wearing a misleading explanation. + if (err.code === "MODULE_NOT_FOUND" && /services[\\/]payroll/.test(err.message)) { + throw new Error( + "Cannot load lib/services/payroll — build the functions first:\n" + + " npm --prefix backend/functions run build", + ); + } + throw err; + } } async function seedExtras() { @@ -683,6 +647,9 @@ async function seedFinance() { const accounts = [ ["1000", "Cash", "ASSET"], ["1010", "Bank", "ASSET"], ["1200", "Accounts Receivable", "ASSET"], ["2000", "Accounts Payable", "LIABILITY"], ["2100", "Salaries Payable", "LIABILITY"], ["2200", "Taxes Payable", "LIABILITY"], + // Credited by the payroll engine; without them the run would create the + // codes itself and this list would stop mirroring the product's chart. + ["2300", "Employee Withholdings", "LIABILITY"], ["2400", "Employer Contributions Payable", "LIABILITY"], ["3000", "Owner's Equity", "EQUITY"], ["4000", "Service Revenue", "INCOME"], ["4100", "Other Income", "INCOME"], ["5000", "Salaries & Wages", "EXPENSE"], ["5100", "Rent", "EXPENSE"], ["5200", "Utilities", "EXPENSE"], @@ -769,8 +736,11 @@ async function main() { await seedAttendance(); await seedLeave(); await seedExtras(); - await seedPayroll(); + // Finance before payroll: the run posts its accrual to the chart of accounts, + // so the chart has to exist first. computePayrollRun would create the codes it + // needs on its own, but then seedFinance would write over them afterwards. await seedFinance(); + await seedPayroll(); console.log(`\n✅ Done. Sample logins (password: ${PASSWORD}):`); console.log(" admin@worktrack.af — COMPANY_ADMIN (web portal)"); console.log(" hr@worktrack.af — HR_ADMIN"); diff --git a/backend/functions/src/services/payroll.integration.test.ts b/backend/functions/src/services/payroll.integration.test.ts index 88891e0..4c64bf5 100644 --- a/backend/functions/src/services/payroll.integration.test.ts +++ b/backend/functions/src/services/payroll.integration.test.ts @@ -611,6 +611,33 @@ describe.skipIf(!EMULATOR)("payroll — the working calendar", () => { expect(run.totalTax).toBe(1900); // the tax on 30000 alone }); + it("does not accrue a still-running month on a date that has not arrived", async () => { + // Dating an in-progress run at the period end puts the whole salary cost + // in the future, and every trend built on the ledger follows it there. + await employee("e1"); + await salary("e1", 30000); + + const { year, month } = currentShamsiMonth(); + await computePayrollRun(cid, year, month, "admin", "AFN"); + + const entry = ( + await tenant(cid, "journalEntries").doc(`PAYROLL_${year}_${String(month).padStart(2, "0")}`).get() + ).data()!; + const today = localDateOf(new Date(), "Asia/Kabul"); + expect(entry.date as string <= today).toBe(true); + }); + + it("accrues a finished month on its last day", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const entry = (await tenant(cid, "journalEntries").doc("PAYROLL_1405_05").get()).data()!; + expect(entry.date).toBe("2026-08-22"); // last day of Shamsi 1405/05 + }); + it("does not dock days that have not happened yet", async () => { // Payroll walks the month's expected working days, so running the month // that is still in progress used to charge every day from today to the end diff --git a/backend/functions/src/services/payroll.ts b/backend/functions/src/services/payroll.ts index 811b5e3..43ae914 100644 --- a/backend/functions/src/services/payroll.ts +++ b/backend/functions/src/services/payroll.ts @@ -425,7 +425,12 @@ export async function computePayrollRun( ); await postJournalEntry(cid, { - date: toIso, + // A completed month accrues on its last day, which is what the books + // expect. A run of a month still in progress must not: dating it at the + // period end puts the whole salary cost on a date that has not arrived, + // so the ledger and every trend built on it show an expense in the + // future. Such a run is recognised on the day it was made. + date: periodComplete ? toIso : todayIso, memo: `Payroll ${periodYear}/${String(periodMonth).padStart(2, "0")}`, reference: runId, source: "PAYROLL", From 9fc72ed95874cf7bfe0274904e2e3afbc5ee3f54 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 22:11:24 -0400 Subject: [PATCH 075/139] fix(portal): make the per-employee amount reachable, and stop serving a stale shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the demo to check the work showed two things a passing test suite had no way to catch. The per-employee section rendered as a three-column table inside the employee dialog, which is ~340px wide whatever the viewport is. The table wanted 409px, so 113px sat outside the visible area and the amount inputs were squeezed to 44px starting past the left edge — the one field the whole feature exists for was, in practice, unreachable. It is now a wrapping row per component: name and meta on one line, switch and amount below. Measured in the live dialog: 110px inputs, entirely inside the card, no sideways scroll. The tests query by label and role rather than by table structure, so all fifteen still pass. Firebase served index.html with max-age=3600 by default. The shell names the hashed bundles, so for an hour after every deploy a returning visitor kept running the previous build — which is exactly what happened here: the page had index-IdOIQU0k.js while index.html already pointed at index-IUvUbD75.js, and I spent three attempts thinking the deploy had not taken. The shell is now no-cache and /assets/** is immutable for a year, which is the right pair for content-hashed filenames. Both projects. The header source had to be "**" rather than "/index.html": Firebase matches headers against the requested path, and a request for "/" never matches "/index.html". The first attempt deployed cleanly and changed nothing. Co-Authored-By: Claude Opus 5 --- firebase.demo.json | 20 ++++++ firebase.json | 60 +++++++++++++--- web/src/pages/EmployeeComponents.tsx | 103 ++++++++++++--------------- web/src/styles.css | 28 ++++++++ 4 files changed, 146 insertions(+), 65 deletions(-) diff --git a/firebase.demo.json b/firebase.demo.json index 7c53ad8..a659b54 100644 --- a/firebase.demo.json +++ b/firebase.demo.json @@ -26,6 +26,26 @@ "source": "**", "destination": "/index.html" } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, max-age=0, must-revalidate" + } + ] + }, + { + "source": "/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } ] } } diff --git a/firebase.json b/firebase.json index 981a06a..4a8e1cb 100644 --- a/firebase.json +++ b/firebase.json @@ -2,7 +2,9 @@ "functions": { "source": "backend/functions", "runtime": "nodejs22", - "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] }, "firestore": { "rules": "backend/firestore.rules", @@ -10,17 +12,57 @@ }, "hosting": { "public": "web/dist", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], "rewrites": [ - { "source": "/v1/**", "function": "api" }, - { "source": "**", "destination": "/index.html" } + { + "source": "/v1/**", + "function": "api" + }, + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, max-age=0, must-revalidate" + } + ] + }, + { + "source": "/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } ] }, "emulators": { - "auth": { "port": 9099 }, - "functions": { "port": 5001 }, - "firestore": { "port": 8080 }, - "hosting": { "port": 5000 }, - "ui": { "enabled": true } + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "hosting": { + "port": 5000 + }, + "ui": { + "enabled": true + } } } diff --git a/web/src/pages/EmployeeComponents.tsx b/web/src/pages/EmployeeComponents.tsx index 9c5da4e..b78b08d 100644 --- a/web/src/pages/EmployeeComponents.tsx +++ b/web/src/pages/EmployeeComponents.tsx @@ -118,62 +118,53 @@ export function EmployeeComponents({ employeeId }: { employeeId: string | null }

{t("empc_title")}

{t("empc_hint")}

-
- - - - - - - - - - {all.map((c) => { - const a = byId.get(c.id); - const on = applies(c, a); - const shown = draft[c.id] ?? (a?.value != null ? String(a.value) : ""); - return ( - - - - - - ); - })} - -
{t("comp_name")}{t("empc_applies")}{t("empc_amount")}
-
{c.name}
-
- {t(`comp_type_${c.type.toLowerCase()}`)} ·{" "} - {appliesByDefault(c) ? t("empc_all_note") : t("empc_individual_note")} ·{" "} - {c.calc === "FIXED" - ? `${num(c.value)} ${t("comp_afn")}` - : `${num(c.value)}٪`} -
-
- void toggle(c, v)} - label={`${t("empc_applies")} — ${c.name}`} - /> - - setDraft({ ...draft, [c.id]: e.target.value })} - onBlur={() => void commitAmount(c)} - aria-label={`${t("empc_amount")} — ${c.name}`} - /> -
-
+ {/* Not a table. This sits inside the employee dialog, which is ~340px + wide whatever the viewport is, and a three-column table there pushed + the amount field — the point of the whole section — out of view at + 44px. A wrapping row survives any container width. */} +
    + {all.map((c) => { + const a = byId.get(c.id); + const on = applies(c, a); + const shown = draft[c.id] ?? (a?.value != null ? String(a.value) : ""); + return ( +
  • +
    +
    {c.name}
    +
    + {t(`comp_type_${c.type.toLowerCase()}`)} ·{" "} + {appliesByDefault(c) ? t("empc_all_note") : t("empc_individual_note")} ·{" "} + {c.calc === "FIXED" ? `${num(c.value)} ${t("comp_afn")}` : `${num(c.value)}٪`} +
    +
    + +
    + + + setDraft({ ...draft, [c.id]: e.target.value })} + onBlur={() => void commitAmount(c)} + aria-label={`${t("empc_amount")} — ${c.name}`} + /> +
    +
  • + ); + })} +

{t("empc_default_hint")} · {t("comp_rerun_hint")} diff --git a/web/src/styles.css b/web/src/styles.css index f04f390..649b092 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1580,3 +1580,31 @@ table.data tbody tr:hover { font-weight: 600; color: var(--text-muted); } + +/* One employee's components. Lives in a ~340px dialog, so the row wraps rather + than scrolling sideways — the amount field must always be reachable. */ +.empc-list { list-style: none; margin: 0; padding: 0; } + +.empc-row { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.empc-row:last-child { border-bottom: none; } + +.empc-name { min-width: 150px; flex: 1 1 150px; } + +.empc-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.empc-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; } + +.empc-amount { width: 110px; } From 1a1360ac17d4a9784bf899a24fa1c5be0f3845a3 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 22:41:38 -0400 Subject: [PATCH 076/139] feat(vendor): a console for Linumic, with a boundary that has to hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issuing a licence needed a terminal, a laptop and project credentials. That is safe — the reason no customer can grant themselves seats is that writing a licence requires something no customer has — but it means the vendor cannot answer "we need two more phones" from a phone, cannot see who is about to expire, and cannot work at all if the laptop is gone. The console inverts this API's central rule: every other route takes the company id from the caller's token, and these take it from the URL. So the identity behind them has to be one no customer can obtain, and two properties give that. The `vendor` claim is written only by scripts/grant-vendor.ts, which needs project credentials — no signup, invite or employee route writes custom claims at all, and ASSIGNABLE_ROLES contains no admin role. And a vendor account must carry no cid/eid: an identity that was both would hold cross-tenant authority while also acting inside a company. grant-vendor.ts refuses to create one — verified against a real customer admin, which it rejects by name — and the middleware refuses to honour one however it arose. The isolation runs both ways, and is tested both ways. requireAuth demands cid/eid, so a vendor token is refused by every tenant route; requireVendor demands their absence, so a tenant token is refused by every console route. Confirmed end to end against the emulator with a real COMPANY_ADMIN token: all four console endpoints returned 403, and the licence it tried to raise to 99,999 seats was still 3 afterwards. The refusal message is identical to the one a stranger gets, so a customer's token cannot even probe that this surface exists. Eleven middleware tests are the attempts to get past it — a company admin, a token claiming a role named SUPER_ADMIN or VENDOR, an account that is both, and a `vendor` claim of "true"/"false"/1/{}/[] against the strict check. With the tenant-claim check removed and the strict equality loosened, two fail. What the console can see is bounded by what it queries: company name, licence, seats in use, headcount. No attendance, no payslips, no employee records — the privacy notice tells every customer Linumic is a processor acting on their written instruction, and a console that browsed their staff would make that untrue. A test asserts no employee data appears in the response. Writes go through the same setLicense the CLI calls, so there is one definition of a licence. Each one lands in two trails: the customer's own audit log, because it is their licence, and vendorAuditLogs outside every tenant, because the customer's copy dies when their company is purged. Co-Authored-By: Claude Opus 5 --- backend/functions/src/app.ts | 7 + .../functions/src/middleware/vendor.test.ts | 119 ++++++ backend/functions/src/middleware/vendor.ts | 96 +++++ .../src/routes/vendor.integration.test.ts | 195 ++++++++++ backend/functions/src/routes/vendor.ts | 149 ++++++++ backend/functions/src/scripts/grant-vendor.ts | 145 ++++++++ backend/functions/src/services/license.ts | 2 +- backend/functions/src/services/vendor.ts | 87 +++++ web/src/App.tsx | 6 + web/src/auth/AuthProvider.tsx | 15 +- web/src/pages/VendorConsole.tsx | 345 ++++++++++++++++++ web/src/styles.css | 39 ++ 12 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 backend/functions/src/middleware/vendor.test.ts create mode 100644 backend/functions/src/middleware/vendor.ts create mode 100644 backend/functions/src/routes/vendor.integration.test.ts create mode 100644 backend/functions/src/routes/vendor.ts create mode 100644 backend/functions/src/scripts/grant-vendor.ts create mode 100644 backend/functions/src/services/vendor.ts create mode 100644 web/src/pages/VendorConsole.tsx diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts index 735740a..98ee69e 100644 --- a/backend/functions/src/app.ts +++ b/backend/functions/src/app.ts @@ -4,6 +4,7 @@ import { isOriginAllowed } from "./lib/cors"; import { errorHandler } from "./lib/errors"; import { requireAuth } from "./middleware/auth"; import { enforceDeviceLicense } from "./middleware/deviceGuard"; +import { vendorRouter } from "./routes/vendor"; import { meRouter } from "./routes/me"; import { attendanceRouter } from "./routes/attendance"; import { leaveRouter } from "./routes/leave"; @@ -51,6 +52,12 @@ export function createApp(): express.Express { // auth middleware so a new company can be created without a token. app.use("/v1/public", publicRouter); + // The vendor console. Mounted OUTSIDE the tenant router on purpose: requireAuth + // demands cid/eid, which a vendor account does not have, and these routes take + // the company id from the URL rather than the token. requireVendor is what + // makes that safe — see middleware/vendor.ts. + app.use("/v1/vendor", vendorRouter); + const v1 = express.Router(); v1.use(requireAuth); // Mounted before the device guard: a phone cannot claim its licence seat if diff --git a/backend/functions/src/middleware/vendor.test.ts b/backend/functions/src/middleware/vendor.test.ts new file mode 100644 index 0000000..9958081 --- /dev/null +++ b/backend/functions/src/middleware/vendor.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { ApiError } from "../lib/errors"; + +/** + * The vendor boundary. + * + * Every other route in this API takes the company id from the token. The vendor + * routes take it from the URL, so the only thing standing between a customer + * and every other customer's data is this middleware. These are the attempts to + * get past it. + */ + +const verify = vi.hoisted(() => ({ impl: async (_t: string) => ({}) as Record })); +vi.mock("firebase-admin/auth", () => ({ + getAuth: () => ({ verifyIdToken: (t: string) => verify.impl(t) }), +})); + +const { requireVendor } = await import("./vendor"); + +function call( + claims: Record | null, + header = "Bearer token", +): Promise<{ err: ApiError | null; vendor: unknown }> { + verify.impl = async () => { + if (!claims) throw new Error("bad token"); + return { uid: "u1", ...claims }; + }; + const req = { header: () => header, vendor: undefined } as unknown as Request; + return new Promise((resolve) => { + void requireVendor(req, {} as Response, (err?: unknown) => + resolve({ err: (err as ApiError) ?? null, vendor: (req as Request).vendor }), + ); + }); +} + +const VENDOR = { vendor: true, email: "staff@linumic.com", email_verified: true }; + +describe("the vendor boundary", () => { + beforeEach(() => { + verify.impl = async () => ({}); + }); + + it("lets verified vendor staff through", async () => { + const { err, vendor } = await call(VENDOR); + expect(err).toBeNull(); + expect(vendor).toEqual({ uid: "u1", email: "staff@linumic.com" }); + }); + + it("refuses a customer's company administrator", async () => { + // The most valuable token an attacker actually has. + const { err } = await call({ + cid: "acme", + eid: "emp_1", + r: ["COMPANY_ADMIN"], + email_verified: true, + }); + expect(err?.status).toBe(403); + }); + + it("refuses a token that merely claims a role named like ours", async () => { + const { err } = await call({ + cid: "acme", + eid: "emp_1", + r: ["SUPER_ADMIN", "VENDOR"], + email_verified: true, + }); + expect(err?.status).toBe(403); + }); + + it("refuses an account that is BOTH staff and an employee", async () => { + // A confused deputy: cross-tenant authority on an identity that also acts + // inside a company. grant-vendor.ts refuses to create one; this refuses to + // honour one however it came to exist. + const { err } = await call({ ...VENDOR, cid: "acme", eid: "emp_1" }); + expect(err?.status).toBe(403); + }); + + it("refuses a vendor claim that is a string rather than true", async () => { + // `"false"`, `"true"` and `1` are all truthy or coercible; the check is + // strict equality for exactly this reason. + for (const v of ["true", "false", 1, {}, [], "vendor"]) { + const { err } = await call({ vendor: v, email_verified: true }); + expect(err?.status, `vendor=${JSON.stringify(v)}`).toBe(403); + } + }); + + it("refuses staff who have not verified their address", async () => { + const { err } = await call({ ...VENDOR, email_verified: false }); + expect(err?.status).toBe(403); + }); + + it("refuses a token with no vendor claim at all", async () => { + const { err } = await call({ email_verified: true }); + expect(err?.status).toBe(403); + }); + + it("refuses an unsigned or expired token", async () => { + const { err } = await call(null); + expect(err?.status).toBe(401); + }); + + it("refuses a request with no Authorization header", async () => { + const { err } = await call(VENDOR, ""); + expect(err?.status).toBe(401); + }); + + it("refuses a header that is not a bearer token", async () => { + const { err } = await call(VENDOR, "Basic c3RhZmY6cGFzcw=="); + expect(err?.status).toBe(401); + }); + + it("does not tell a customer that this surface exists", async () => { + // A distinctive message would confirm there is a vendor console to attack. + const asCustomer = await call({ cid: "acme", eid: "e1", email_verified: true }); + const asNobody = await call({ email_verified: true }); + expect(asCustomer.err?.message).toBe(asNobody.err?.message); + }); +}); diff --git a/backend/functions/src/middleware/vendor.ts b/backend/functions/src/middleware/vendor.ts new file mode 100644 index 0000000..190df7a --- /dev/null +++ b/backend/functions/src/middleware/vendor.ts @@ -0,0 +1,96 @@ +import type { NextFunction, Request, Response } from "express"; +import { getAuth } from "firebase-admin/auth"; +import { ApiError } from "../lib/errors"; + +/** + * The vendor: Linumic staff, not any customer's employee. + * + * Everything else in this API takes the company id from the caller's token and + * never from the request, which is what makes one customer unable to read + * another's data. The vendor routes deliberately invert that — they take the + * company id from the URL — so the identity behind them has to be one that no + * customer can ever obtain. Two properties give that: + * + * 1. The `vendor` claim is set only by scripts/grant-vendor.ts, which needs + * credentials for the Firebase project itself. No signup, invite or + * employee route can write a custom claim at all, and the assignable-role + * list (services/invite.ts) contains no admin role of any kind. + * + * 2. A vendor account must carry NO tenant claims. An identity that is both + * would be a confused deputy: it could act on a company through the tenant + * routes while carrying cross-tenant authority. grant-vendor.ts refuses to + * create one and this middleware refuses to honour one. + * + * These routes are mounted outside requireAuth, which demands cid/eid — so a + * vendor token is rejected by every tenant route, and a tenant token is + * rejected here. The two identities cannot be used in each other's half of the + * product, in either direction. + */ + +export interface VendorContext { + uid: string; + email: string | null; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + vendor?: VendorContext; + } + } +} + +export async function requireVendor( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const header = req.header("Authorization") ?? ""; + const match = header.match(/^Bearer (.+)$/); + if (!match) { + throw ApiError.unauthenticated(); + } + + const decoded = await getAuth() + .verifyIdToken(match[1]) + .catch(() => { + throw ApiError.unauthenticated("Token is invalid or expired"); + }); + + if (decoded.vendor !== true) { + // Deliberately the same message a tenant user gets: whether this surface + // exists is not something a customer's token should be able to probe. + throw ApiError.permissionDenied("Not permitted"); + } + + // See (2) above. This is the check that keeps the inversion safe. + if (decoded.cid || decoded.eid) { + throw ApiError.permissionDenied("Not permitted"); + } + + // Staff sign in with a password like anyone else; an unverified address + // must not carry cross-tenant authority. + if (decoded.email_verified !== true) { + throw ApiError.permissionDenied("Verify your email address first"); + } + + req.vendor = { + uid: decoded.uid, + email: (decoded.email as string | undefined) ?? null, + }; + next(); + } catch (err) { + next(err); + } +} + +/** Non-null accessor for handlers running behind requireVendor. */ +export function vendorOf(req: Request): VendorContext { + const v = req.vendor; + if (!v) { + throw ApiError.unauthenticated(); + } + return v; +} diff --git a/backend/functions/src/routes/vendor.integration.test.ts b/backend/functions/src/routes/vendor.integration.test.ts new file mode 100644 index 0000000..b7bc1fb --- /dev/null +++ b/backend/functions/src/routes/vendor.integration.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db, tenant } from "../lib/firestore"; + +/** + * The vendor console, through the real Express app. + * + * The middleware tests prove a customer's token cannot reach these routes. This + * proves the other half: that the routes do what they are for, that a vendor + * token cannot reach the TENANT routes, and that the reach of the console is + * bounded to company-level facts rather than anybody's staff. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +/** Minimal request driver: enough to exercise the real middleware chain. */ +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +const VENDOR = { uid: "vendor_1", vendor: true, email: "staff@linumic.com", email_verified: true }; +const ADMIN = (cid: string) => ({ + uid: "u_admin", + cid, + eid: "emp_1", + r: ["COMPANY_ADMIN"], + email_verified: true, +}); + +let cidA = ""; +let cidB = ""; +let seq = 0; + +describe.skipIf(!EMULATOR)("the vendor console", () => { + beforeEach(async () => { + seq += 1; + cidA = `ven_a_${Date.now()}_${seq}`; + cidB = `ven_b_${Date.now()}_${seq}`; + await db.collection("companies").doc(cidA).set({ name: "Acme Kabul", status: "ACTIVE" }); + await db.collection("companies").doc(cidB).set({ + name: "Beta Herat", + status: "ACTIVE", + license: { plan: "STANDARD", deviceLimit: 3, status: "ACTIVE", expiresAt: "2020-01-01", enforceDevices: true }, + }); + await tenant(cidA, "employees").doc("e1").set({ firstName: "A", lastName: "B", status: "ACTIVE" }); + await tenant(cidA, "devices").doc("d1").set({ status: "ACTIVE", type: "MOBILE" }); + await tenant(cidA, "devices").doc("d2").set({ status: "REVOKED", type: "MOBILE" }); + token.claims = { ...VENDOR }; + }); + + it("lists every company with its licence and seat usage", async () => { + const res = await request("GET", "/v1/vendor/companies"); + expect(res.status).toBe(200); + + const rows = res.body.data as Array>; + const a = rows.find((r) => r.companyId === cidA)!; + expect(a.name).toBe("Acme Kabul"); + expect(a.employeeCount).toBe(1); + expect(a.devicesInUse).toBe(1); // the revoked one does not hold a seat + expect((a.license as Record).deviceLimit).toBe(5); // the default + }); + + it("puts whatever is about to break first", async () => { + const rows = (await request("GET", "/v1/vendor/companies")).body.data as Array< + Record + >; + const expired = rows.findIndex((r) => r.companyId === cidB); + const fine = rows.findIndex((r) => r.companyId === cidA); + expect(expired).toBeLessThan(fine); + expect(rows[expired].daysUntilExpiry as number).toBeLessThan(0); + }); + + it("issues a licence, and the customer's own audit trail records it", async () => { + const res = await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "STANDARD", + deviceLimit: 3, + status: "ACTIVE", + expiresAt: "2027-03-20", + enforceDevices: true, + }); + expect(res.status).toBe(200); + expect((res.body.data as Record).deviceLimit).toBe(3); + + const stored = (await db.collection("companies").doc(cidA).get()).data()!; + expect(stored.license.deviceLimit).toBe(3); + expect(stored.license.enforceDevices).toBe(true); + + const trail = await tenant(cidA, "auditLogs").where("action", "==", "license.update").get(); + expect(trail.size).toBe(1); + expect(trail.docs[0].data().actorRole).toBe("VENDOR"); + }); + + it("keeps its own record, outside any tenant", async () => { + // The customer's copy dies with their tenant; this one is the vendor's. + await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "FREE", deviceLimit: 1, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + + const log = await db.collection("vendorAuditLogs").where("companyId", "==", cidA).get(); + expect(log.size).toBe(1); + const entry = log.docs[0].data(); + expect(entry.actorEmail).toBe("staff@linumic.com"); + expect(entry.before.deviceLimit).toBe(5); + expect(entry.after.deviceLimit).toBe(1); + }); + + it("refuses a licence for a company that does not exist", async () => { + const res = await request("PUT", "/v1/vendor/companies/no_such_company/license", { + plan: "FREE", deviceLimit: 1, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + expect(res.status).toBe(404); + }); + + it("validates the licence body rather than storing anything sent", async () => { + const res = await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "UNLIMITED", deviceLimit: -5, status: "WHATEVER", enforceDevices: "yes", + }); + expect(res.status).toBe(422); // the house convention for a bad body + const stored = (await db.collection("companies").doc(cidA).get()).data()!; + expect(stored.license).toBeUndefined(); + }); + + it("shuts a customer's token out of the console entirely", async () => { + token.claims = ADMIN(cidA); + for (const [method, path] of [ + ["GET", "/v1/vendor/companies"], + ["GET", `/v1/vendor/companies/${cidB}`], + ["GET", "/v1/vendor/audit"], + ["GET", "/v1/vendor/me"], + ] as const) { + const res = await request(method, path); + expect(res.status, `${method} ${path}`).toBe(403); + } + const write = await request("PUT", `/v1/vendor/companies/${cidB}/license`, { + plan: "ENTERPRISE", deviceLimit: 99999, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + expect(write.status).toBe(403); + const untouched = (await db.collection("companies").doc(cidB).get()).data()!; + expect(untouched.license.deviceLimit).toBe(3); + }); + + it("shuts a vendor token out of the tenant routes", async () => { + // The other direction: cross-tenant authority must not become the ability + // to act inside one company through the ordinary API. + token.claims = { ...VENDOR }; + for (const path of ["/v1/me", "/v1/employees", "/v1/payroll/components"]) { + const res = await request("GET", path); + expect([401, 403], path).toContain(res.status); + } + }); + + it("does not expose anybody's staff", async () => { + // The console is bounded to company-level facts by what it queries. If that + // ever changes, the privacy notice stops being true. + const body = JSON.stringify((await request("GET", "/v1/vendor/companies")).body); + expect(body).not.toContain("emp_1"); + expect(body).not.toMatch(/firstName|lastName|payslip|attendance/i); + }); +}); diff --git a/backend/functions/src/routes/vendor.ts b/backend/functions/src/routes/vendor.ts new file mode 100644 index 0000000..1f8a07c --- /dev/null +++ b/backend/functions/src/routes/vendor.ts @@ -0,0 +1,149 @@ +import { Router } from "express"; +import { asyncHandler, ApiError } from "../lib/errors"; +import { audit, db, nowTimestamp } from "../lib/firestore"; +import { parseBody } from "../middleware/validate"; +import { requireVendor, vendorOf } from "../middleware/vendor"; +import { licenseWriteSchema, setLicense, getLicense } from "../services/license"; +import { getCompany, listCompanies } from "../services/vendor"; +import { localDateOf } from "../services/attendance"; + +/** + * The vendor console's API: Linumic's own view across every customer. + * + * Every route here reads the company id from the URL rather than from the + * caller's token — the opposite of the rest of this API, and the reason + * requireVendor is as strict as it is. Nothing else in the product may be + * mounted on this router. + */ +export const vendorRouter = Router(); + +vendorRouter.use(requireVendor); + +/** The vendor's own date, used only to compute "days until expiry" for display. */ +function today(): string { + return localDateOf(new Date(), "Asia/Kabul"); +} + +/** + * A record of what the vendor did, kept outside every tenant. + * + * The customer's own audit trail also gets the entry — a licence change is + * something they are entitled to see — but that copy lives inside a tenant that + * can be closed and purged. This one is the vendor's, and survives it. + */ +async function vendorAudit(entry: { + actorUid: string; + actorEmail: string | null; + action: string; + companyId: string; + before?: unknown; + after?: unknown; +}): Promise { + try { + await db.collection("vendorAuditLogs").add({ + ...entry, + before: entry.before ?? null, + after: entry.after ?? null, + at: nowTimestamp(), + }); + } catch (e) { + console.error("VENDOR_AUDIT_WRITE_FAILED", { action: entry.action, error: e }); + } +} + +/** Every customer, with whatever is about to break listed first. */ +vendorRouter.get( + "/companies", + asyncHandler(async (_req, res) => { + res.json({ data: await listCompanies(today()) }); + }), +); + +vendorRouter.get( + "/companies/:companyId", + asyncHandler(async (req, res) => { + const row = await getCompany(req.params.companyId, today()); + if (!row) throw ApiError.notFound("Company not found"); + res.json({ data: row }); + }), +); + +/** + * Issue or change a licence. + * + * The same setLicense the CLI calls, so there is one implementation of what a + * licence is and the console cannot drift from the script. + */ +vendorRouter.put( + "/companies/:companyId/license", + asyncHandler(async (req, res) => { + const vendor = vendorOf(req); + const { companyId } = req.params; + const payload = parseBody(req, licenseWriteSchema); + + const company = await db.collection("companies").doc(companyId).get(); + if (!company.exists) throw ApiError.notFound("Company not found"); + + const before = await getLicense(companyId); + const after = await setLicense(companyId, payload); + + // Both trails: the customer's, because it is their licence, and the + // vendor's, because it outlives their tenant. + await Promise.all([ + audit(companyId, { + actorId: vendor.uid, + actorRole: "VENDOR", + action: "license.update", + resourceType: "companies", + resourceId: companyId, + before, + after, + }), + vendorAudit({ + actorUid: vendor.uid, + actorEmail: vendor.email, + action: "license.update", + companyId, + before, + after, + }), + ]); + + res.json({ data: after }); + }), +); + +/** What the vendor has done, newest first. */ +vendorRouter.get( + "/audit", + asyncHandler(async (_req, res) => { + const snap = await db + .collection("vendorAuditLogs") + .orderBy("at", "desc") + .limit(200) + .get(); + res.json({ + data: snap.docs.map((d) => { + const v = d.data(); + return { + id: d.id, + actorEmail: v.actorEmail ?? null, + action: v.action, + companyId: v.companyId, + before: v.before ?? null, + after: v.after ?? null, + at: v.at?.toDate?.().toISOString() ?? null, + }; + }), + }); + }), +); + +/** Confirms to the console that the token really is a vendor one. */ +vendorRouter.get( + "/me", + asyncHandler(async (req, res) => { + const v = vendorOf(req); + res.json({ data: { uid: v.uid, email: v.email, vendor: true } }); + }), +); diff --git a/backend/functions/src/scripts/grant-vendor.ts b/backend/functions/src/scripts/grant-vendor.ts new file mode 100644 index 0000000..c7ad6b2 --- /dev/null +++ b/backend/functions/src/scripts/grant-vendor.ts @@ -0,0 +1,145 @@ +/* + * Grants (or revokes) vendor access — Linumic staff, not any customer. + * + * A vendor account can read every company and write any licence, so the claim + * that marks one must be unobtainable through the product. It is: no signup, + * invite or employee route writes custom claims at all, and this script needs + * credentials for the Firebase project itself. + * + * The account must already exist. Create it in the Firebase console — Claude + * does not create accounts or handle passwords — then run this against it. + * + * Usage (from backend/functions, after `npm run build`): + * + * # Always start here: prints what would change, writes nothing. + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/grant-vendor.js \ + * --email you@linumic.com + * + * # Grant + * ... --email you@linumic.com --apply + * + * # Take it away + * ... --email someone@linumic.com --revoke --apply + * + * # Who has it + * ... --list + * + * Credentials come from Application Default Credentials; run + * `gcloud auth application-default login` first. + * + * After a grant the person must sign out and back in: custom claims reach the + * client in a fresh ID token, not the one already in their browser. + */ + +import { getAuth } from "firebase-admin/auth"; +import { initializeApp, applicationDefault, getApps } from "firebase-admin/app"; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function flag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +function fail(message: string): never { + console.error(`\n ✗ ${message}\n`); + process.exit(1); +} + +async function main(): Promise { + const projectId = + process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || ""; + if (!projectId) { + fail("Set GOOGLE_CLOUD_PROJECT to the Firebase project, e.g. worktrack-prod"); + } + if (!getApps().length) { + initializeApp({ credential: applicationDefault(), projectId }); + } + const auth = getAuth(); + + console.log(`\n project: ${projectId}\n`); + + if (flag("list")) { + // Small staff list; one page is plenty and paging every user of the project + // to find them would be wasteful. + const page = await auth.listUsers(1000); + const staff = page.users.filter((u) => u.customClaims?.vendor === true); + if (!staff.length) { + console.log(" Nobody has vendor access.\n"); + return; + } + for (const u of staff) { + console.log(` ${u.email ?? u.uid}`); + console.log(` uid ${u.uid} email verified: ${u.emailVerified ? "yes" : "NO"}\n`); + } + return; + } + + const email = arg("email"); + if (!email) fail("--email is required (or use --list)"); + + const user = await auth.getUserByEmail(email).catch(() => null); + if (!user) { + fail( + `No account for ${email} in ${projectId}.\n` + + " Create it in the Firebase console first — Authentication → Users → Add user.", + ); + } + + const claims = user.customClaims ?? {}; + const revoking = flag("revoke"); + + // A vendor identity must carry no tenant claims. One that did could act on a + // company through the ordinary routes while also holding cross-tenant + // authority — and middleware/vendor.ts refuses such a token anyway, so + // granting it here would produce an account that simply does not work. + if (!revoking && (claims.cid || claims.eid)) { + fail( + `${email} is an employee of company ${claims.cid}.\n` + + " A vendor account must not belong to any customer. Use a separate\n" + + " address for staff access.", + ); + } + + if (!revoking && !user.emailVerified) { + console.log( + ` ! ${email} has not verified its address. The claim can be set now, but\n` + + " the console will refuse the token until it is verified.\n", + ); + } + + const has = claims.vendor === true; + console.log(` account: ${email}`); + console.log(` now: vendor access ${has ? "GRANTED" : "not granted"}`); + console.log(` next: vendor access ${revoking ? "not granted" : "GRANTED"}`); + + if (has === !revoking) { + console.log("\n Nothing would change.\n"); + return; + } + + if (!flag("apply")) { + console.log("\n Dry run — nothing written. Re-run with --apply.\n"); + return; + } + + const next = { ...claims }; + if (revoking) delete next.vendor; + else next.vendor = true; + await auth.setCustomUserClaims(user.uid, next); + + // Existing ID tokens keep working for up to an hour; revoking must bite now. + if (revoking) { + await auth.revokeRefreshTokens(user.uid); + console.log("\n ✓ Vendor access removed, and existing sessions revoked.\n"); + } else { + console.log("\n ✓ Vendor access granted. Sign out and back in to pick it up.\n"); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/backend/functions/src/services/license.ts b/backend/functions/src/services/license.ts index 5c33cb8..9a0d404 100644 --- a/backend/functions/src/services/license.ts +++ b/backend/functions/src/services/license.ts @@ -76,7 +76,7 @@ export interface DeviceDto { lastSeenAt: string | null; } -interface DeviceDoc { +export interface DeviceDoc { type?: string; label?: string | null; platform?: string | null; diff --git a/backend/functions/src/services/vendor.ts b/backend/functions/src/services/vendor.ts new file mode 100644 index 0000000..194d34a --- /dev/null +++ b/backend/functions/src/services/vendor.ts @@ -0,0 +1,87 @@ +import { db, tenant, toIso } from "../lib/firestore"; +import { DEFAULT_LICENSE, isDeviceActive } from "./license"; +import type { DeviceDoc, License } from "./license"; + +/** + * What the vendor can see across every customer. + * + * Deliberately company-level only: name, licence, how many seats are in use, + * how many people are on the books. No attendance, no payslips, no employee + * records. The privacy notice tells every customer that Linumic is a processor + * acting on their written instruction, and a console that browsed their staff + * would make that untrue — so the reach of this surface is bounded here, in + * the queries, rather than by remembering not to look. + */ + +export interface CompanySummary { + companyId: string; + name: string; + status: string; + license: License; + /** Seats occupied by a registered, non-revoked device. */ + devicesInUse: number; + employeeCount: number; + /** Null when the licence never expires. */ + daysUntilExpiry: number | null; + createdAt: string | null; + deletion: { status: string; purgeAfter: string | null } | null; +} + +function daysBetween(fromIso: string, toIsoDate: string): number { + const a = Date.parse(`${fromIso}T00:00:00Z`); + const b = Date.parse(`${toIsoDate}T00:00:00Z`); + return Math.round((b - a) / 86_400_000); +} + +async function summarise( + doc: FirebaseFirestore.QueryDocumentSnapshot, + todayIso: string, +): Promise { + const d = doc.data(); + const license: License = { ...DEFAULT_LICENSE, ...(d.license ?? {}) }; + + // count() aggregations rather than reading the documents: the vendor needs + // the number, not the people. + const [deviceSnap, employeeAgg] = await Promise.all([ + tenant(doc.id, "devices").limit(1000).get(), + tenant(doc.id, "employees").where("status", "==", "ACTIVE").count().get(), + ]); + + const deletion = d.deletion as { status?: string; purgeAfter?: string } | undefined; + + return { + companyId: doc.id, + name: (d.name as string) ?? "(unnamed)", + status: (d.status as string) ?? "ACTIVE", + license, + devicesInUse: deviceSnap.docs.filter((x) => isDeviceActive(x.data() as DeviceDoc)).length, + employeeCount: employeeAgg.data().count, + daysUntilExpiry: license.expiresAt ? daysBetween(todayIso, license.expiresAt) : null, + createdAt: toIso(d.createdAt ?? null), + deletion: deletion?.status + ? { status: deletion.status, purgeAfter: deletion.purgeAfter ?? null } + : null, + }; +} + +export async function listCompanies(todayIso: string): Promise { + const snap = await db.collection("companies").limit(500).get(); + const rows = await Promise.all(snap.docs.map((d) => summarise(d, todayIso))); + // Whatever needs attention soonest, first: expired, then expiring, then the + // rest. A vendor opening this wants to know what is about to break. + return rows.sort((a, b) => { + const av = a.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER; + const bv = b.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER; + if (av !== bv) return av - bv; + return a.name.localeCompare(b.name); + }); +} + +export async function getCompany( + companyId: string, + todayIso: string, +): Promise { + const doc = await db.collection("companies").doc(companyId).get(); + if (!doc.exists) return null; + return summarise(doc as FirebaseFirestore.QueryDocumentSnapshot, todayIso); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d14d36a..ef094f4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { FinancePage } from "./pages/FinancePage"; import { SettingsPage } from "./pages/SettingsPage"; import { KioskPage } from "./pages/KioskPage"; import { DevicesPage } from "./pages/DevicesPage"; +import { VendorConsole } from "./pages/VendorConsole"; export function App() { const { status } = useAuth(); @@ -23,6 +24,11 @@ export function App() { if (status === "signedOut") { return ; } + // Linumic staff get their own console, not the customer portal — they have + // no company, and every tenant route would refuse their token anyway. + if (status === "vendor") { + return ; + } // A dedicated kiosk device is locked to the full-screen check-in display. if (status === "kiosk") { return ; diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx index 51d7d93..3d6c6eb 100644 --- a/web/src/auth/AuthProvider.tsx +++ b/web/src/auth/AuthProvider.tsx @@ -15,7 +15,7 @@ import { auth } from "../firebase"; import { api, ApiError } from "../api/client"; import type { CompanyFeatures, Me } from "../api/types"; -type Status = "loading" | "signedOut" | "signedIn" | "kiosk"; +type Status = "loading" | "signedOut" | "signedIn" | "kiosk" | "vendor"; interface AuthContextValue { status: Status; @@ -65,6 +65,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { // A dedicated kiosk device account has no employee record; route it // straight to the full-screen kiosk display from its token claims. const claims = await user.getIdTokenResult(); + // Linumic staff. They have no employee record and no company, so /me + // would refuse them — the console is a different application that + // happens to be served from the same bundle. + if (claims.claims.vendor === true) { + setMe(null); + setStatus("vendor"); + return; + } if (asRoles(claims.claims.r).includes("KIOSK")) { setMe(null); setStatus("kiosk"); @@ -95,6 +103,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { signIn: async (email, password) => { const cred = await signInWithEmailAndPassword(auth, email.trim(), password); const claims = await cred.user.getIdTokenResult(); + if (claims.claims.vendor === true) { + setMe(null); + setStatus("vendor"); + return; + } if (asRoles(claims.claims.r).includes("KIOSK")) { setMe(null); setStatus("kiosk"); diff --git a/web/src/pages/VendorConsole.tsx b/web/src/pages/VendorConsole.tsx new file mode 100644 index 0000000..a5233da --- /dev/null +++ b/web/src/pages/VendorConsole.tsx @@ -0,0 +1,345 @@ +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { useAuth } from "../auth/AuthProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +/** + * Linumic's own console: every customer, their licence, and what is about to + * expire. + * + * Deliberately in English and outside the tenant portal's shell — this is not a + * customer-facing screen and should never be mistaken for one. It shows + * company-level facts only; there is no route here to anybody's staff, and the + * server would refuse one. + */ + +interface License { + plan: "FREE" | "STANDARD" | "ENTERPRISE"; + deviceLimit: number; + status: "ACTIVE" | "SUSPENDED" | "EXPIRED"; + expiresAt: string | null; + enforceDevices: boolean; +} + +interface CompanySummary { + companyId: string; + name: string; + status: string; + license: License; + devicesInUse: number; + employeeCount: number; + daysUntilExpiry: number | null; + deletion: { status: string; purgeAfter: string | null } | null; +} + +const PLANS: License["plan"][] = ["FREE", "STANDARD", "ENTERPRISE"]; +const STATUSES: License["status"][] = ["ACTIVE", "SUSPENDED", "EXPIRED"]; + +function expiryTone(days: number | null): "positive" | "warning" | "negative" { + if (days === null) return "positive"; + if (days < 0) return "negative"; + if (days <= 30) return "warning"; + return "positive"; +} + +function expiryLabel(c: CompanySummary): string { + if (!c.license.expiresAt) return "No expiry"; + const d = c.daysUntilExpiry; + if (d === null) return c.license.expiresAt; + if (d < 0) return `Expired ${-d}d ago`; + if (d === 0) return "Expires today"; + return `${d}d left`; +} + +export function VendorConsole() { + const { signOut } = useAuth(); + const [rows, setRows] = useState(null); + const [error, setError] = useState(false); + const [editing, setEditing] = useState(null); + const [toast, setToast] = useState(null); + const [filter, setFilter] = useState(""); + + async function load() { + setError(false); + try { + const { data } = await api.get("/vendor/companies"); + setRows(data); + } catch { + setError(true); + } + } + + useEffect(() => { + void load(); + }, []); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2600); + }; + + const shown = (rows ?? []).filter((c) => { + const q = filter.trim().toLowerCase(); + return !q || c.name.toLowerCase().includes(q) || c.companyId.toLowerCase().includes(q); + }); + + const expiringSoon = (rows ?? []).filter( + (c) => c.daysUntilExpiry !== null && c.daysUntilExpiry <= 30, + ).length; + + return ( +

+
+
+

Linumic — customers

+

+ {rows ? `${rows.length} companies` : "…"} + {expiringSoon > 0 && ` · ${expiringSoon} expiring within 30 days`} +

+
+
+ setFilter(e.target.value)} + /> + + +
+
+ + {error ? ( + void load()} /> + ) : !rows ? ( + + ) : shown.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {shown.map((c) => { + const full = c.devicesInUse >= c.license.deviceLimit; + return ( + + + + + + + + + + ); + })} + +
CompanyPlanSeatsPeopleEnforcedExpiry +
+
{c.name}
+
+ {c.companyId} +
+ {c.deletion && ( + + closing · purge {c.deletion.purgeAfter ?? "—"} + + )} +
{c.license.plan} + + {c.devicesInUse} / {c.license.deviceLimit} + + {c.employeeCount} + + {c.license.enforceDevices ? "yes" : "no"} + + + {expiryLabel(c)} + + +
+
+ )} + + {editing && ( + setEditing(null)} + onSaved={async () => { + setEditing(null); + await load(); + flash("Licence updated"); + }} + /> + )} + + {toast && } +
+ ); +} + +function LicenceEditor({ + company, + onClose, + onSaved, +}: { + company: CompanySummary; + onClose: () => void; + onSaved: () => void | Promise; +}) { + const [form, setForm] = useState({ ...company.license }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function save() { + setError(null); + if (!Number.isInteger(form.deviceLimit) || form.deviceLimit < 1) { + return setError("Seats must be a whole number of 1 or more."); + } + if (form.expiresAt && !/^\d{4}-\d{2}-\d{2}$/.test(form.expiresAt)) { + return setError("Expiry must be YYYY-MM-DD, or empty for none."); + } + setBusy(true); + try { + await api.put(`/vendor/companies/${company.companyId}/license`, { + ...form, + expiresAt: form.expiresAt || null, + }); + await onSaved(); + } catch { + setError("Could not save. The change was not applied."); + } finally { + setBusy(false); + } + } + + const shrinking = form.deviceLimit < company.devicesInUse; + + return ( +
+
e.stopPropagation()}> +

{company.name}

+

+ {company.companyId} +

+ +
+
+ + +
+ +
+ + setForm({ ...form, deviceLimit: Number(e.target.value) })} + /> + {company.devicesInUse} in use +
+ +
+ + +
+ +
+ + setForm({ ...form, expiresAt: e.target.value || null })} + /> + Empty = never +
+
+ + + + {shrinking && ( +

+ {company.devicesInUse} devices are registered but this grants{" "} + {form.deviceLimit}. Registered devices keep working; the next new one is + refused. +

+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 649b092..7193f4b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1608,3 +1608,42 @@ table.data tbody tr:hover { .empc-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; } .empc-amount { width: 110px; } + +/* Linumic's own console. Not a customer screen: LTR, its own chrome, and + deliberately unlike the tenant portal so the two are never confused. */ +.vendor { + max-width: 1100px; + margin: 0 auto; + padding: 28px 20px 60px; +} + +.vendor-head { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 20px; +} +.vendor-head h1 { margin: 0 0 2px; font-size: 21px; } + +.vendor-overlay { + position: fixed; + inset: 0; + background: rgba(10, 39, 53, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 50; +} + +.vendor-dialog { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; + width: min(640px, 100%); + max-height: 90vh; + overflow: auto; +} From 09627685b3ccad9083ef27fd6447f8e680b371a5 Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 22:51:56 -0400 Subject: [PATCH 077/139] fix(vendor): the setup step I wrote could not actually be carried out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grant-vendor told the operator to verify the address before running it. Adding a user in the Firebase console leaves emailVerified false and offers no way to change it, so that instruction was a dead end — the account would be created, the claim would be set, and the console would refuse the token with nothing in the UI to fix it. The script marks the address verified as part of granting. That is legitimate for this account and only this account: it is created by whoever owns the project, with credentials only they have, for an address they chose. There is no stranger's self-asserted address here — which is what the check in middleware/vendor.ts guards against, and why that check stays exactly as it is. The dry run says "also: mark the address verified" before doing it, so nothing happens unannounced. Verified against an account created the way the console creates one (emailVerified false): the grant reports both changes, and that account then signs in and reaches the console. The twenty boundary tests still pass. Co-Authored-By: Claude Opus 5 --- backend/functions/src/scripts/grant-vendor.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/backend/functions/src/scripts/grant-vendor.ts b/backend/functions/src/scripts/grant-vendor.ts index c7ad6b2..392e247 100644 --- a/backend/functions/src/scripts/grant-vendor.ts +++ b/backend/functions/src/scripts/grant-vendor.ts @@ -6,8 +6,10 @@ * invite or employee route writes custom claims at all, and this script needs * credentials for the Firebase project itself. * - * The account must already exist. Create it in the Firebase console — Claude - * does not create accounts or handle passwords — then run this against it. + * The account must already exist. Create it in the Firebase console + * (Authentication → Users → Add user) — Claude does not create accounts or + * handle passwords — then run this against it. The console leaves the address + * unverified and offers no way to change that, so this script marks it. * * Usage (from backend/functions, after `npm run build`): * @@ -103,19 +105,27 @@ async function main(): Promise { ); } - if (!revoking && !user.emailVerified) { - console.log( - ` ! ${email} has not verified its address. The claim can be set now, but\n` + - " the console will refuse the token until it is verified.\n", - ); - } + // Adding a user in the Firebase console leaves emailVerified false and gives + // no way to change it, so requiring the address be verified beforehand asked + // for something that cannot be done. Mark it here instead. + // + // That is legitimate for this account and only this account: it is created by + // whoever owns the project, using credentials only they have, for an address + // they chose. There is no stranger's self-asserted address to guard against — + // which is what the check in middleware/vendor.ts exists for, and why that + // check stays. + const needsVerifying = !revoking && !user.emailVerified; const has = claims.vendor === true; console.log(` account: ${email}`); console.log(` now: vendor access ${has ? "GRANTED" : "not granted"}`); console.log(` next: vendor access ${revoking ? "not granted" : "GRANTED"}`); - if (has === !revoking) { + if (needsVerifying) { + console.log(" also: mark the address verified"); + } + + if (has === !revoking && !needsVerifying) { console.log("\n Nothing would change.\n"); return; } @@ -129,6 +139,10 @@ async function main(): Promise { if (revoking) delete next.vendor; else next.vendor = true; await auth.setCustomUserClaims(user.uid, next); + if (needsVerifying) { + await auth.updateUser(user.uid, { emailVerified: true }); + console.log("\n ✓ Address marked verified (the console cannot do this)."); + } // Existing ID tokens keep working for up to an hour; revoking must bite now. if (revoking) { From 31200db85398bae23bf07cefb655820a2f445ece Mon Sep 17 00:00:00 2001 From: Aminullah Hashemi Date: Mon, 7 Sep 2026 23:21:30 -0400 Subject: [PATCH 078/139] feat(vendor): a CRM, on top of the data the console already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You asked for the whole thing after I argued against it, so this is the whole thing. My reservation stands and is worth writing down: there are no paying customers yet — the three companies in production are the same business signed up three times — so this will be empty for a while, and empty tooling is maintenance without return. You have the time and it is your call. Six entities: accounts, contacts, activities, deals, invoices and tickets. They are the same shape of thing, so the routes are generated from one table rather than written six times; divergence between them would be a bug, not a feature. All of it sits behind the vendor claim, and the boundary tests cover the CRM paths too — a customer's token gets 403 on read and write alike. The data lives in top-level collections, outside every tenant. It is Linumic's record of its customers, not any customer's data: a company that closes its account purges its own tree, and the vendor's memory of the deal has to survive that for an invoice or a dispute. Deleting an account cascades to its children, or an unpaid invoice would haunt the dashboard with no account left to open. An account is not a tenant. That is the point of a pipeline: a prospect exists here before they exist in the product, and `companyId` gets filled in when they buy. The console is two halves that meet on the Today tab — live companies with their licences on one side, people being sold to on the other. Five tabs. Today is the one that earns it: follow-ups due, licences expiring, money owed, issues open, each row opening the account behind it. Pipeline is a board by stage. Money and Support are the cross-account cuts. Every change is audited to a person, so a disputed figure can be traced. One bug found by using it rather than testing it: marking an invoice paid updated the dialog but left the dashboard behind it showing the money as still owed until someone happened to press Refresh. Child changes now reload the parent silently. Watched an open-ticket count go 2 → 1 with no manual refresh. Co-Authored-By: Claude Opus 5 --- .../src/routes/crm.integration.test.ts | 255 +++++++ backend/functions/src/routes/vendor.ts | 115 +++ backend/functions/src/services/crm.ts | 300 ++++++++ web/src/pages/VendorConsole.tsx | 670 +++++++++++++----- web/src/pages/vendor/AccountDetail.tsx | 629 ++++++++++++++++ web/src/pages/vendor/api.ts | 50 ++ web/src/pages/vendor/types.ts | 129 ++++ web/src/styles.css | 85 +++ 8 files changed, 2073 insertions(+), 160 deletions(-) create mode 100644 backend/functions/src/routes/crm.integration.test.ts create mode 100644 backend/functions/src/services/crm.ts create mode 100644 web/src/pages/vendor/AccountDetail.tsx create mode 100644 web/src/pages/vendor/api.ts create mode 100644 web/src/pages/vendor/types.ts diff --git a/backend/functions/src/routes/crm.integration.test.ts b/backend/functions/src/routes/crm.integration.test.ts new file mode 100644 index 0000000..fa91b17 --- /dev/null +++ b/backend/functions/src/routes/crm.integration.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db } from "../lib/firestore"; + +/** + * The vendor's CRM, through the real app. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +const VENDOR = { uid: "vendor_1", vendor: true, email: "staff@linumic.com", email_verified: true }; +const CUSTOMER = { uid: "u1", cid: "acme", eid: "e1", r: ["COMPANY_ADMIN"], email_verified: true }; + +const day = (n: number) => + new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10); + +/** The CRM lives outside every tenant, so each test starts from a clean slate. */ +async function wipe(): Promise { + for (const c of [ + "crmAccounts", + "crmContacts", + "crmActivities", + "crmDeals", + "crmInvoices", + "crmTickets", + ]) { + const snap = await db.collection(c).get(); + const batch = db.batch(); + snap.docs.forEach((d) => batch.delete(d.ref)); + if (snap.size) await batch.commit(); + } +} + +async function newAccount(over: Record = {}): Promise { + const res = await request("POST", "/v1/vendor/crm/accounts", { + name: "Kabul Textiles", + stage: "LEAD", + city: "Kabul", + ...over, + }); + expect(res.status).toBe(201); + return String((res.body.data as Record).id); +} + +describe.skipIf(!EMULATOR)("the vendor CRM", () => { + beforeEach(async () => { + token.claims = { ...VENDOR }; + await wipe(); + }); + + it("keeps a prospect before they are any kind of customer", async () => { + // The whole point of a pipeline: a record with no tenant behind it. + const id = await newAccount({ employeesEstimate: 45, source: "referral" }); + const row = (await request("GET", `/v1/vendor/crm/accounts/${id}`)).body.data as Record< + string, + unknown + >; + expect(row.name).toBe("Kabul Textiles"); + expect(row.stage).toBe("LEAD"); + expect(row.companyId ?? null).toBeNull(); + expect(row.createdBy).toBe("vendor_1"); + }); + + it("moves an account along the pipeline and links it to the live tenant", async () => { + const id = await newAccount(); + const res = await request("PUT", `/v1/vendor/crm/accounts/${id}`, { + name: "Kabul Textiles", + stage: "WON", + companyId: "comp_kabul", + }); + expect(res.status).toBe(200); + expect((res.body.data as Record).stage).toBe("WON"); + expect((res.body.data as Record).companyId).toBe("comp_kabul"); + }); + + it("carries contacts, activities, deals, invoices and tickets for an account", async () => { + const accountId = await newAccount(); + const made = [ + ["contacts", { accountId, name: "Ahmad", phone: "+93 700 000 000", primary: true }], + ["activities", { accountId, kind: "CALL", at: day(0), summary: "Talked about seats" }], + ["deals", { accountId, seats: 25, amountAfn: 60000, term: "YEARLY", status: "SENT" }], + ["invoices", { accountId, number: "INV-001", amountAfn: 60000, issuedAt: day(-10), dueAt: day(-3), status: "SENT" }], + ["tickets", { accountId, subject: "App will not install", openedAt: day(-1), priority: "HIGH" }], + ] as const; + + for (const [path, payload] of made) { + const res = await request("POST", `/v1/vendor/crm/${path}`, payload); + expect(res.status, path).toBe(201); + } + + for (const [path] of made) { + const list = (await request("GET", `/v1/vendor/crm/${path}?accountId=${accountId}`)).body + .data as unknown[]; + expect(list.length, path).toBe(1); + } + }); + + it("filters by account rather than returning everybody's", async () => { + const a = await newAccount({ name: "A" }); + const b = await newAccount({ name: "B" }); + await request("POST", "/v1/vendor/crm/activities", { + accountId: a, kind: "CALL", at: day(0), summary: "for A", + }); + await request("POST", "/v1/vendor/crm/activities", { + accountId: b, kind: "CALL", at: day(0), summary: "for B", + }); + + const forA = (await request("GET", `/v1/vendor/crm/activities?accountId=${a}`)).body + .data as Array>; + expect(forA).toHaveLength(1); + expect(forA[0].summary).toBe("for A"); + }); + + it("shows what is due, what is owed and what is broken, in one call", async () => { + const overdue = await newAccount({ name: "Overdue", nextActionAt: day(-2), nextAction: "Call back" }); + const soon = await newAccount({ name: "Soon", nextActionAt: day(3), nextAction: "Send quote" }); + await newAccount({ name: "Later", nextActionAt: day(30) }); + await newAccount({ name: "No action" }); + + await request("POST", "/v1/vendor/crm/invoices", { + accountId: overdue, number: "INV-1", amountAfn: 40000, issuedAt: day(-20), dueAt: day(-5), status: "SENT", + }); + await request("POST", "/v1/vendor/crm/invoices", { + accountId: soon, number: "INV-2", amountAfn: 15000, issuedAt: day(-2), status: "PAID", paidAt: day(-1), method: "HAWALA", + }); + await request("POST", "/v1/vendor/crm/deals", { + accountId: soon, seats: 10, amountAfn: 90000, status: "SENT", + }); + await request("POST", "/v1/vendor/crm/tickets", { + accountId: overdue, subject: "Cannot sign in", openedAt: day(-3), + }); + + const d = (await request("GET", "/v1/vendor/crm/dashboard")).body.data as Record; + + expect((d.dueNow as unknown[]).length).toBe(1); + expect((d.dueSoon as unknown[]).length).toBe(1); + // Only the unpaid one, and only its amount. + expect((d.unpaidInvoices as unknown[]).length).toBe(1); + expect(d.outstandingAfn).toBe(40000); + expect(d.openPipelineAfn).toBe(90000); // the sent quote, not the paid invoice + expect((d.openTickets as unknown[]).length).toBe(1); + expect((d.pipeline as Record).LEAD).toBe(4); + }); + + it("deleting an account takes its records with it", async () => { + // Otherwise an unpaid invoice would haunt the dashboard with no account to + // open and no way to reach it. + const accountId = await newAccount(); + await request("POST", "/v1/vendor/crm/invoices", { + accountId, number: "INV-9", amountAfn: 1000, issuedAt: day(-1), status: "SENT", + }); + await request("POST", "/v1/vendor/crm/tickets", { + accountId, subject: "x", openedAt: day(-1), + }); + + expect((await request("DELETE", `/v1/vendor/crm/accounts/${accountId}`)).status).toBe(204); + + const d = (await request("GET", "/v1/vendor/crm/dashboard")).body.data as Record; + expect((d.unpaidInvoices as unknown[]).length).toBe(0); + expect((d.openTickets as unknown[]).length).toBe(0); + expect(d.outstandingAfn).toBe(0); + }); + + it("validates rather than storing whatever it is sent", async () => { + const accountId = await newAccount(); + const bad = [ + ["accounts", { name: "", stage: "MAYBE" }], + ["deals", { accountId, seats: 0, amountAfn: -5 }], + ["invoices", { accountId, number: "", amountAfn: 1, issuedAt: "not-a-date" }], + ["tickets", { accountId, subject: "x", openedAt: day(0), priority: "WHENEVER" }], + ] as const; + for (const [path, payload] of bad) { + const res = await request("POST", `/v1/vendor/crm/${path}`, payload); + expect(res.status, path).toBe(422); + } + }); + + it("records every change against the person who made it", async () => { + const id = await newAccount(); + await request("PUT", `/v1/vendor/crm/accounts/${id}`, { name: "Renamed", stage: "DEMO" }); + + const log = await db.collection("vendorAuditLogs").get(); + const actions = log.docs.map((d) => d.data().action); + expect(actions).toContain("crm.accounts.create"); + expect(actions).toContain("crm.accounts.update"); + expect(log.docs.every((d) => d.data().actorEmail === "staff@linumic.com")).toBe(true); + }); + + it("is shut to a customer's token, read and write alike", async () => { + const id = await newAccount(); + token.claims = { ...CUSTOMER }; + + for (const [m, p] of [ + ["GET", "/v1/vendor/crm/accounts"], + ["GET", "/v1/vendor/crm/dashboard"], + ["GET", `/v1/vendor/crm/accounts/${id}`], + ["POST", "/v1/vendor/crm/accounts"], + ["DELETE", `/v1/vendor/crm/accounts/${id}`], + ] as const) { + const res = await request(m, p, m === "POST" ? { name: "theirs" } : undefined); + expect(res.status, `${m} ${p}`).toBe(403); + } + + token.claims = { ...VENDOR }; + expect(((await request("GET", "/v1/vendor/crm/accounts")).body.data as unknown[]).length).toBe(1); + }); + + it("404s for something that is not there rather than inventing it", async () => { + expect((await request("GET", "/v1/vendor/crm/accounts/nope")).status).toBe(404); + expect( + (await request("PUT", "/v1/vendor/crm/accounts/nope", { name: "x" })).status, + ).toBe(404); + expect((await request("DELETE", "/v1/vendor/crm/tickets/nope")).status).toBe(404); + }); +}); diff --git a/backend/functions/src/routes/vendor.ts b/backend/functions/src/routes/vendor.ts index 1f8a07c..0a4dc56 100644 --- a/backend/functions/src/routes/vendor.ts +++ b/backend/functions/src/routes/vendor.ts @@ -1,10 +1,20 @@ import { Router } from "express"; +import type { Request } from "express"; import { asyncHandler, ApiError } from "../lib/errors"; import { audit, db, nowTimestamp } from "../lib/firestore"; import { parseBody } from "../middleware/validate"; import { requireVendor, vendorOf } from "../middleware/vendor"; import { licenseWriteSchema, setLicense, getLicense } from "../services/license"; import { getCompany, listCompanies } from "../services/vendor"; +import * as crm from "../services/crm"; +import { + accountWriteSchema, + activityWriteSchema, + contactWriteSchema, + dealWriteSchema, + invoiceWriteSchema, + ticketWriteSchema, +} from "../services/crm"; import { localDateOf } from "../services/attendance"; /** @@ -147,3 +157,108 @@ vendorRouter.get( res.json({ data: { uid: v.uid, email: v.email, vendor: true } }); }), ); + +/* ---------------------------------------------------------------------- CRM */ + +/** + * The vendor's own customer records. + * + * All six entities are the same shape of thing — a document with an owner, a + * schema and an optional account — so they are mounted from one table rather + * than written out six times. Divergence between them would be a bug, not a + * feature. + */ +const CRM_ENTITIES = [ + { path: "accounts", collection: "crmAccounts", schema: accountWriteSchema }, + { path: "contacts", collection: "crmContacts", schema: contactWriteSchema }, + { path: "activities", collection: "crmActivities", schema: activityWriteSchema }, + { path: "deals", collection: "crmDeals", schema: dealWriteSchema }, + { path: "invoices", collection: "crmInvoices", schema: invoiceWriteSchema }, + { path: "tickets", collection: "crmTickets", schema: ticketWriteSchema }, +] as const; + +/** What the vendor did, so a disputed figure can be traced to a person. */ +async function crmAudit( + req: Request, + action: string, + id: string, + before?: unknown, + after?: unknown, +): Promise { + const v = vendorOf(req); + await vendorAudit({ + actorUid: v.uid, + actorEmail: v.email, + action, + companyId: String((after as Record)?.companyId ?? id), + before, + after, + }); +} + +for (const entity of CRM_ENTITIES) { + const base = `/crm/${entity.path}`; + + vendorRouter.get( + base, + asyncHandler(async (req, res) => { + const accountId = req.query.accountId ? String(req.query.accountId) : undefined; + res.json({ data: await crm.list(entity.collection, accountId) }); + }), + ); + + vendorRouter.get( + `${base}/:id`, + asyncHandler(async (req, res) => { + const row = await crm.get(entity.collection, req.params.id); + if (!row) throw ApiError.notFound("Not found"); + res.json({ data: row }); + }), + ); + + vendorRouter.post( + base, + asyncHandler(async (req, res) => { + const payload = parseBody(req, entity.schema); + const row = await crm.create(entity.collection, payload, vendorOf(req).uid); + await crmAudit(req, `crm.${entity.path}.create`, String(row.id), null, row); + res.status(201).json({ data: row }); + }), + ); + + vendorRouter.put( + `${base}/:id`, + asyncHandler(async (req, res) => { + const payload = parseBody(req, entity.schema); + const before = await crm.get(entity.collection, req.params.id); + const row = await crm.update(entity.collection, req.params.id, payload, vendorOf(req).uid); + if (!row) throw ApiError.notFound("Not found"); + await crmAudit(req, `crm.${entity.path}.update`, req.params.id, before, row); + res.json({ data: row }); + }), + ); + + vendorRouter.delete( + `${base}/:id`, + asyncHandler(async (req, res) => { + const before = await crm.get(entity.collection, req.params.id); + // Deleting an account takes its children with it; anything else is a + // plain delete. + const gone = + entity.path === "accounts" + ? ((await crm.deleteAccountCascade(req.params.id)), true) + : await crm.remove(entity.collection, req.params.id); + if (!gone) throw ApiError.notFound("Not found"); + await crmAudit(req, `crm.${entity.path}.delete`, req.params.id, before, null); + res.status(204).send(); + }), + ); +} + +/** Everything that needs the vendor's attention today, in one call. */ +vendorRouter.get( + "/crm/dashboard", + asyncHandler(async (_req, res) => { + res.json({ data: await crm.dashboard(today()) }); + }), +); diff --git a/backend/functions/src/services/crm.ts b/backend/functions/src/services/crm.ts new file mode 100644 index 0000000..02ac176 --- /dev/null +++ b/backend/functions/src/services/crm.ts @@ -0,0 +1,300 @@ +import { z } from "zod"; +import { db, nowTimestamp, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * The vendor's own record of the people it sells to. + * + * This is Linumic's data about its customers, not any customer's data, so it + * lives in top-level collections outside every tenant. A company closing its + * account purges its own tree; the vendor's memory of the deal survives that, + * as it must for an invoice or a dispute. + * + * Flat collections rather than subcollections under an account, because the + * questions that matter cut across accounts: what is due this week, what is + * unpaid, which tickets are open. A subcollection per account would make each + * of those a fan-out. + * + * An account may or may not point at a live tenant. Before the sale it does + * not — that is the whole point of a pipeline — and `companyId` is filled in + * when they become a customer. + */ + +/* ------------------------------------------------------------------ accounts */ + +export const ACCOUNT_STAGES = [ + "LEAD", + "CONTACTED", + "DEMO", + "QUOTED", + "WON", + "LOST", + "DORMANT", +] as const; +export type AccountStage = (typeof ACCOUNT_STAGES)[number]; + +export const accountWriteSchema = z.object({ + name: z.string().min(1).max(120), + stage: z.enum(ACCOUNT_STAGES).default("LEAD"), + /** Set once they are a paying tenant; links this record to the live company. */ + companyId: z.string().max(64).nullish(), + city: z.string().max(80).nullish(), + industry: z.string().max(80).nullish(), + /** Where they came from: a referral, the website, the demo, a visit. */ + source: z.string().max(80).nullish(), + /** Their own estimate of headcount, before they are onboarded. */ + employeesEstimate: z.number().int().min(0).max(1_000_000).nullish(), + /** The next thing the vendor must do, and when. The heart of following up. */ + nextActionAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + nextAction: z.string().max(200).nullish(), + notes: z.string().max(4000).nullish(), +}); +export type AccountWrite = z.infer; + +/* ------------------------------------------------------------------ contacts */ + +export const contactWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + name: z.string().min(1).max(120), + role: z.string().max(80).nullish(), + /** Phone first: this market runs on calls, not email. */ + phone: z.string().max(40).nullish(), + email: z.string().max(160).nullish(), + /** The person decisions actually go through. */ + primary: z.boolean().default(false), + notes: z.string().max(1000).nullish(), +}); + +/* ---------------------------------------------------------------- activities */ + +export const ACTIVITY_KINDS = ["CALL", "MEETING", "MESSAGE", "EMAIL", "VISIT", "NOTE"] as const; + +export const activityWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + kind: z.enum(ACTIVITY_KINDS).default("NOTE"), + at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + summary: z.string().min(1).max(2000), + contactId: z.string().max(64).nullish(), +}); + +/* --------------------------------------------------------------------- deals */ + +export const DEAL_STATUSES = ["DRAFT", "SENT", "ACCEPTED", "REJECTED"] as const; + +export const dealWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + status: z.enum(DEAL_STATUSES).default("DRAFT"), + plan: z.enum(["FREE", "STANDARD", "ENTERPRISE"]).default("STANDARD"), + seats: z.number().int().min(1).max(100_000), + /** AFN. There is no payment rail here; this is what was agreed, in writing. */ + amountAfn: z.number().min(0).max(1_000_000_000), + /** MONTHLY or YEARLY — what the amount covers. */ + term: z.enum(["MONTHLY", "YEARLY", "ONE_OFF"]).default("YEARLY"), + quotedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + notes: z.string().max(2000).nullish(), +}); + +/* ------------------------------------------------------------------ invoices */ + +export const INVOICE_STATUSES = ["DRAFT", "SENT", "PAID", "VOID"] as const; +export const PAYMENT_METHODS = ["BANK", "CASH", "HAWALA", "OTHER"] as const; + +export const invoiceWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + number: z.string().min(1).max(40), + status: z.enum(INVOICE_STATUSES).default("DRAFT"), + amountAfn: z.number().min(0).max(1_000_000_000), + issuedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + dueAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + paidAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + method: z.enum(PAYMENT_METHODS).nullish(), + /** Covers which period, in the vendor's own words. */ + period: z.string().max(80).nullish(), + notes: z.string().max(1000).nullish(), +}); + +/* ------------------------------------------------------------------- tickets */ + +export const TICKET_STATUSES = ["OPEN", "WAITING", "RESOLVED"] as const; +export const TICKET_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"] as const; + +export const ticketWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + subject: z.string().min(1).max(200), + status: z.enum(TICKET_STATUSES).default("OPEN"), + priority: z.enum(TICKET_PRIORITIES).default("NORMAL"), + openedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + resolvedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + detail: z.string().max(4000).nullish(), + resolution: z.string().max(4000).nullish(), +}); + +/* ------------------------------------------------------------------- storage */ + +/** The CRM's collections. Top-level: this is the vendor's data, not a tenant's. */ +export type CrmCollection = + | "crmAccounts" + | "crmContacts" + | "crmActivities" + | "crmDeals" + | "crmInvoices" + | "crmTickets"; + +function col(name: CrmCollection): FirebaseFirestore.CollectionReference { + return db.collection(name); +} + +function shape(doc: FirebaseFirestore.DocumentSnapshot): Record { + const d = doc.data() ?? {}; + return { + id: doc.id, + ...d, + createdAt: toIso(d.createdAt ?? null), + updatedAt: toIso(d.updatedAt ?? null), + }; +} + +export async function create( + collection: CrmCollection, + data: Record, + actor: string, +): Promise> { + const id = ulid(); + const now = nowTimestamp(); + await col(collection) + .doc(id) + .create({ ...data, createdBy: actor, createdAt: now, updatedAt: now }); + return shape(await col(collection).doc(id).get()); +} + +export async function update( + collection: CrmCollection, + id: string, + data: Record, + actor: string, +): Promise | null> { + const ref = col(collection).doc(id); + if (!(await ref.get()).exists) return null; + await ref.set({ ...data, updatedBy: actor, updatedAt: nowTimestamp() }, { merge: true }); + return shape(await ref.get()); +} + +export async function remove(collection: CrmCollection, id: string): Promise { + const ref = col(collection).doc(id); + if (!(await ref.get()).exists) return false; + await ref.delete(); + return true; +} + +export async function get( + collection: CrmCollection, + id: string, +): Promise | null> { + const doc = await col(collection).doc(id).get(); + return doc.exists ? shape(doc) : null; +} + +/** Everything in a collection, or everything belonging to one account. */ +export async function list( + collection: CrmCollection, + accountId?: string, +): Promise>> { + let q: FirebaseFirestore.Query = col(collection); + if (accountId) q = q.where("accountId", "==", accountId); + const snap = await q.limit(2000).get(); + return snap.docs.map(shape); +} + +/** + * Deleting an account takes its contacts, activities, deals, invoices and + * tickets with it. Leaving them behind would keep them in every cross-account + * view — unpaid invoices for a company that is no longer listed — with no way + * to reach them. + */ +export async function deleteAccountCascade(accountId: string): Promise { + const children: CrmCollection[] = [ + "crmContacts", + "crmActivities", + "crmDeals", + "crmInvoices", + "crmTickets", + ]; + let removed = 0; + for (const c of children) { + const snap = await col(c).where("accountId", "==", accountId).limit(2000).get(); + const batch = db.batch(); + snap.docs.forEach((d) => batch.delete(d.ref)); + if (snap.size) await batch.commit(); + removed += snap.size; + } + await col("crmAccounts").doc(accountId).delete(); + return removed; +} + +/* ----------------------------------------------------------------- dashboard */ + +export interface CrmDashboard { + /** Follow-ups whose date has arrived or passed. */ + dueNow: Array>; + /** Follow-ups in the next seven days. */ + dueSoon: Array>; + /** Sent but not paid, oldest first. */ + unpaidInvoices: Array>; + openTickets: Array>; + /** Count of accounts in each stage. */ + pipeline: Record; + /** AFN in quotes that have been sent but not decided. */ + openPipelineAfn: number; + /** AFN invoiced and unpaid. */ + outstandingAfn: number; +} + +export async function dashboard(todayIso: string): Promise { + const [accounts, invoices, tickets, deals] = await Promise.all([ + list("crmAccounts"), + list("crmInvoices"), + list("crmTickets"), + list("crmDeals"), + ]); + + const inSevenDays = new Date(Date.parse(`${todayIso}T00:00:00Z`) + 7 * 86_400_000) + .toISOString() + .slice(0, 10); + + const withAction = accounts.filter((a) => typeof a.nextActionAt === "string"); + const dueNow = withAction + .filter((a) => (a.nextActionAt as string) <= todayIso) + .sort((a, b) => String(a.nextActionAt).localeCompare(String(b.nextActionAt))); + const dueSoon = withAction + .filter( + (a) => (a.nextActionAt as string) > todayIso && (a.nextActionAt as string) <= inSevenDays, + ) + .sort((a, b) => String(a.nextActionAt).localeCompare(String(b.nextActionAt))); + + const unpaidInvoices = invoices + .filter((i) => i.status === "SENT") + .sort((a, b) => String(a.dueAt ?? a.issuedAt).localeCompare(String(b.dueAt ?? b.issuedAt))); + + const openTickets = tickets + .filter((t) => t.status !== "RESOLVED") + .sort((a, b) => String(a.openedAt).localeCompare(String(b.openedAt))); + + const pipeline: Record = {}; + for (const stage of ACCOUNT_STAGES) pipeline[stage] = 0; + for (const a of accounts) { + const s = String(a.stage ?? "LEAD"); + pipeline[s] = (pipeline[s] ?? 0) + 1; + } + + return { + dueNow, + dueSoon, + unpaidInvoices, + openTickets, + pipeline, + openPipelineAfn: deals + .filter((d) => d.status === "SENT") + .reduce((s, d) => s + Number(d.amountAfn ?? 0), 0), + outstandingAfn: unpaidInvoices.reduce((s, i) => s + Number(i.amountAfn ?? 0), 0), + }; +} diff --git a/web/src/pages/VendorConsole.tsx b/web/src/pages/VendorConsole.tsx index a5233da..37f28f8 100644 --- a/web/src/pages/VendorConsole.tsx +++ b/web/src/pages/VendorConsole.tsx @@ -1,76 +1,59 @@ import { useEffect, useState } from "react"; -import { api } from "../api/client"; import { useAuth } from "../auth/AuthProvider"; import { Chip, EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; +import { vendorApi } from "./vendor/api"; +import { AccountDetail } from "./vendor/AccountDetail"; +import { afn, STAGES, todayIso } from "./vendor/types"; +import type { Account, CompanySummary, Dashboard, License } from "./vendor/types"; /** - * Linumic's own console: every customer, their licence, and what is about to - * expire. + * Linumic's own console. * * Deliberately in English and outside the tenant portal's shell — this is not a - * customer-facing screen and should never be mistaken for one. It shows - * company-level facts only; there is no route here to anybody's staff, and the - * server would refuse one. + * customer-facing screen and should never be mistaken for one. Two halves: the + * customers who exist as live tenants and their licences, and the CRM of people + * being sold to, who may not be tenants at all yet. */ -interface License { - plan: "FREE" | "STANDARD" | "ENTERPRISE"; - deviceLimit: number; - status: "ACTIVE" | "SUSPENDED" | "EXPIRED"; - expiresAt: string | null; - enforceDevices: boolean; -} - -interface CompanySummary { - companyId: string; - name: string; - status: string; - license: License; - devicesInUse: number; - employeeCount: number; - daysUntilExpiry: number | null; - deletion: { status: string; purgeAfter: string | null } | null; -} +type Tab = "today" | "pipeline" | "customers" | "money" | "support"; -const PLANS: License["plan"][] = ["FREE", "STANDARD", "ENTERPRISE"]; -const STATUSES: License["status"][] = ["ACTIVE", "SUSPENDED", "EXPIRED"]; - -function expiryTone(days: number | null): "positive" | "warning" | "negative" { - if (days === null) return "positive"; - if (days < 0) return "negative"; - if (days <= 30) return "warning"; - return "positive"; -} - -function expiryLabel(c: CompanySummary): string { - if (!c.license.expiresAt) return "No expiry"; - const d = c.daysUntilExpiry; - if (d === null) return c.license.expiresAt; - if (d < 0) return `Expired ${-d}d ago`; - if (d === 0) return "Expires today"; - return `${d}d left`; -} +const TABS: Array<{ id: Tab; label: string }> = [ + { id: "today", label: "Today" }, + { id: "pipeline", label: "Pipeline" }, + { id: "customers", label: "Customers" }, + { id: "money", label: "Money" }, + { id: "support", label: "Support" }, +]; export function VendorConsole() { const { signOut } = useAuth(); - const [rows, setRows] = useState(null); + const [tab, setTab] = useState("today"); + const [companies, setCompanies] = useState(null); + const [accounts, setAccounts] = useState(null); + const [board, setBoard] = useState(null); const [error, setError] = useState(false); - const [editing, setEditing] = useState(null); + const [openAccount, setOpenAccount] = useState(null); + const [licenceFor, setLicenceFor] = useState(null); const [toast, setToast] = useState(null); - const [filter, setFilter] = useState(""); - async function load() { + async function loadAll() { setError(false); try { - const { data } = await api.get("/vendor/companies"); - setRows(data); + const [c, a, d] = await Promise.all([ + vendorApi.companies(), + vendorApi.accounts.list(), + vendorApi.dashboard(), + ]); + setCompanies(c); + setAccounts(a); + setBoard(d); } catch { setError(true); } } useEffect(() => { - void load(); + void loadAll(); }, []); const flash = (m: string) => { @@ -78,34 +61,25 @@ export function VendorConsole() { window.setTimeout(() => setToast(null), 2600); }; - const shown = (rows ?? []).filter((c) => { - const q = filter.trim().toLowerCase(); - return !q || c.name.toLowerCase().includes(q) || c.companyId.toLowerCase().includes(q); - }); - - const expiringSoon = (rows ?? []).filter( + const expiringSoon = (companies ?? []).filter( (c) => c.daysUntilExpiry !== null && c.daysUntilExpiry <= 30, ).length; + const needsAttention = + (board?.dueNow.length ?? 0) + expiringSoon + (board?.openTickets.length ?? 0); return (
-

Linumic — customers

+

Linumic

- {rows ? `${rows.length} companies` : "…"} - {expiringSoon > 0 && ` · ${expiringSoon} expiring within 30 days`} + {companies ? `${companies.length} live` : "…"} + {accounts && ` · ${accounts.length} in the pipeline`} + {needsAttention > 0 && ` · ${needsAttention} needing attention`}

-
- setFilter(e.target.value)} - /> -
+ + {error ? ( - void load()} /> - ) : !rows ? ( + void loadAll()} /> + ) : !companies || !accounts || !board ? ( - ) : shown.length === 0 ? ( - + ) : ( + <> + {tab === "today" && ( + + )} + {tab === "pipeline" && ( + { + await loadAll(); + flash("Added"); + }} + /> + )} + {tab === "customers" && ( + + )} + {tab === "money" && } + {tab === "support" && } + + )} + + {openAccount && ( + setOpenAccount(null)} + onChanged={async () => { + await loadAll(); + flash("Saved"); + }} + // Silent: an invoice being paid should update the totals behind the + // dialog without a toast for every keystroke-sized change. + onDataChanged={loadAll} + /> + )} + + {licenceFor && ( + setLicenceFor(null)} + onSaved={async () => { + setLicenceFor(null); + await loadAll(); + flash("Licence updated"); + }} + /> + )} + + {toast && } +
+ ); +} + +/* ------------------------------------------------------------------- today */ + +function Today({ + board, + companies, + accounts, + onOpen, +}: { + board: Dashboard; + companies: CompanySummary[]; + accounts: Account[]; + onOpen: (a: Account) => void; +}) { + const byId = new Map(accounts.map((a) => [a.id, a])); + const expiring = companies + .filter((c) => c.daysUntilExpiry !== null && c.daysUntilExpiry <= 30) + .sort((a, b) => (a.daysUntilExpiry ?? 0) - (b.daysUntilExpiry ?? 0)); + + const nothing = + board.dueNow.length === 0 && + board.dueSoon.length === 0 && + expiring.length === 0 && + board.unpaidInvoices.length === 0 && + board.openTickets.length === 0; + + if (nothing) { + return ; + } + + return ( +
+ + {board.dueNow.map((a) => ( + onOpen(a)}> + {a.name} + {a.nextAction ?? "—"} + {a.nextActionAt} + + ))} + + + + {expiring.map((c) => ( + + {c.name} + + {c.devicesInUse}/{c.license.deviceLimit} seats + + + {(c.daysUntilExpiry ?? 0) < 0 + ? `expired ${-(c.daysUntilExpiry ?? 0)}d ago` + : `${c.daysUntilExpiry}d left`} + + + ))} + + + + {board.dueSoon.map((a) => ( + onOpen(a)}> + {a.name} + {a.nextAction ?? "—"} + {a.nextActionAt} + + ))} + + + 0 ? afn(board.outstandingAfn) : undefined} + > + {board.unpaidInvoices.map((i) => { + const a = byId.get(i.accountId); + return ( + onOpen(a) : undefined}> + {i.number} + {a?.name ?? i.accountId} + {afn(i.amountAfn)} + {i.dueAt && due {i.dueAt}} + + ); + })} + + + + {board.openTickets.map((t) => { + const a = byId.get(t.accountId); + return ( + onOpen(a) : undefined}> + {t.priority} + {t.subject} + {a?.name ?? t.accountId} + + ); + })} + +
+ ); +} + +function Card({ + title, + count, + tone, + note, + children, +}: { + title: string; + count: number; + tone: "negative" | "warning" | "neutral"; + note?: string; + children: React.ReactNode; +}) { + if (count === 0) return null; + return ( +
+
+

{title}

+ {note ?? count} +
+
    {children}
+
+ ); +} + +function Row({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) { + return ( +
  • e.key === "Enter" && onClick() : undefined} + > +
    {children}
    +
  • + ); +} + +/* ---------------------------------------------------------------- pipeline */ + +function Pipeline({ + accounts, + onOpen, + onAdded, +}: { + accounts: Account[]; + onOpen: (a: Account) => void; + onAdded: () => Promise; +}) { + const [name, setName] = useState(""); + const [filter, setFilter] = useState(""); + + const shown = accounts.filter((a) => { + const q = filter.trim().toLowerCase(); + return !q || a.name.toLowerCase().includes(q) || (a.city ?? "").toLowerCase().includes(q); + }); + + return ( + <> +
    + setName(e.target.value)} + /> + + setFilter(e.target.value)} + /> +
    + + {shown.length === 0 ? ( + + ) : ( +
    + {STAGES.map((stage) => { + const inStage = shown.filter((a) => a.stage === stage); + return ( +
    +

    + {stage} {inStage.length} +

    + {inStage.map((a) => ( + + ))} +
    + ); + })} +
    + )} + + ); +} + +/* --------------------------------------------------------------- customers */ + +function Customers({ + companies, + onLicence, +}: { + companies: CompanySummary[]; + onLicence: (c: CompanySummary) => void; +}) { + const [filter, setFilter] = useState(""); + const shown = companies.filter((c) => { + const q = filter.trim().toLowerCase(); + return !q || c.name.toLowerCase().includes(q) || c.companyId.toLowerCase().includes(q); + }); + + return ( + <> + setFilter(e.target.value)} + /> + {shown.length === 0 ? ( + ) : (
    @@ -135,66 +420,154 @@ export function VendorConsole() { - {shown.map((c) => { - const full = c.devicesInUse >= c.license.deviceLimit; - return ( - - - - - - - - - - ); - })} + {shown.map((c) => ( + + + + + + + + + + ))}
    -
    {c.name}
    -
    - {c.companyId} -
    - {c.deletion && ( - - closing · purge {c.deletion.purgeAfter ?? "—"} - - )} -
    {c.license.plan} - - {c.devicesInUse} / {c.license.deviceLimit} - - {c.employeeCount} - - {c.license.enforceDevices ? "yes" : "no"} - - - {expiryLabel(c)} - - -
    +
    {c.name}
    +
    + {c.companyId} +
    + {c.deletion && ( + closing · purge {c.deletion.purgeAfter ?? "—"} + )} +
    {c.license.plan} + = c.license.deviceLimit ? "warning" : "neutral"}> + {c.devicesInUse} / {c.license.deviceLimit} + + {c.employeeCount} + + {c.license.enforceDevices ? "yes" : "no"} + + + + {c.license.expiresAt === null + ? "No expiry" + : (c.daysUntilExpiry ?? 0) < 0 + ? `Expired ${-(c.daysUntilExpiry ?? 0)}d ago` + : `${c.daysUntilExpiry}d left`} + + + +
    )} + + ); +} - {editing && ( - setEditing(null)} - onSaved={async () => { - setEditing(null); - await load(); - flash("Licence updated"); - }} - /> +/* -------------------------------------------------------------------- money */ + +function Money({ + board, + accounts, + onOpen, +}: { + board: Dashboard; + accounts: Account[]; + onOpen: (a: Account) => void; +}) { + const byId = new Map(accounts.map((a) => [a.id, a])); + return ( + <> +
    + + + +
    + {board.unpaidInvoices.length === 0 ? ( + + ) : ( +
      + {board.unpaidInvoices.map((i) => { + const a = byId.get(i.accountId); + return ( + onOpen(a) : undefined}> + {i.number} + {a?.name ?? i.accountId} + {afn(i.amountAfn)} + issued {i.issuedAt} + {i.dueAt && ( + due {i.dueAt} + )} + + ); + })} +
    )} + + ); +} - {toast && } +function Stat({ label, value, tone }: { label: string; value: string; tone: "warning" | "neutral" }) { + return ( +
    + {label} + {value}
    ); } +/* ------------------------------------------------------------------ support */ + +function Support({ + board, + accounts, + onOpen, +}: { + board: Dashboard; + accounts: Account[]; + onOpen: (a: Account) => void; +}) { + const byId = new Map(accounts.map((a) => [a.id, a])); + if (board.openTickets.length === 0) { + return ; + } + return ( +
      + {board.openTickets.map((t) => { + const a = byId.get(t.accountId); + return ( + onOpen(a) : undefined}> + {t.priority} + {t.status} + {t.subject} + + {a?.name ?? t.accountId} · opened {t.openedAt} + + + ); + })} +
    + ); +} + +/* ------------------------------------------------------------------ licence */ + +const PLANS: License["plan"][] = ["FREE", "STANDARD", "ENTERPRISE"]; +const STATUSES: License["status"][] = ["ACTIVE", "SUSPENDED", "EXPIRED"]; + function LicenceEditor({ company, onClose, @@ -213,12 +586,9 @@ function LicenceEditor({ if (!Number.isInteger(form.deviceLimit) || form.deviceLimit < 1) { return setError("Seats must be a whole number of 1 or more."); } - if (form.expiresAt && !/^\d{4}-\d{2}-\d{2}$/.test(form.expiresAt)) { - return setError("Expiry must be YYYY-MM-DD, or empty for none."); - } setBusy(true); try { - await api.put(`/vendor/companies/${company.companyId}/license`, { + await vendorApi.setLicense(company.companyId, { ...form, expiresAt: form.expiresAt || null, }); @@ -241,30 +611,21 @@ function LicenceEditor({

    -
    - +
    - -
    - + +
    - -
    - + +
    - -
    - + +
    +