initial commit
This commit is contained in:
commit
4873449e16
69
.gitea/workflows/deploy.yml
Normal file
69
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
name: Build & Deploy Fiddy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.nicosaya.com/nalalangan/fiddy
|
||||||
|
IMAGE_TAG: main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test --if-present
|
||||||
|
|
||||||
|
- name: Docker login
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build Web Image
|
||||||
|
run: |
|
||||||
|
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:${{ env.IMAGE_TAG }} -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: Push Web Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/web:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/web:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install SSH key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Upload docker-compose.yml
|
||||||
|
run: |
|
||||||
|
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/fiddy"
|
||||||
|
scp docker-compose.yml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/fiddy/docker-compose.yml
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
run: |
|
||||||
|
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
|
cd /opt/fiddy
|
||||||
|
export IMAGE_TAG=main
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
|
EOF
|
||||||
123
.github/copilot-instructions.md
vendored
Normal file
123
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Copilot Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
||||||
|
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
||||||
|
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
|
||||||
|
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Monorepo (npm workspaces)
|
||||||
|
- Next.js (App Router) + TypeScript + Tailwind
|
||||||
|
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
||||||
|
- Docker Compose dev/prod
|
||||||
|
- Gitea + act-runner CI/CD
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||||
|
- `DATABASE_URL` points to the external DB server (NOT a container).
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
- Custom email/password auth.
|
||||||
|
- Use HttpOnly session cookies backed by DB table `sessions`.
|
||||||
|
- NEVER trust client-side RBAC checks.
|
||||||
|
|
||||||
|
## Receipts
|
||||||
|
- Store receipt images in Postgres `bytea` table `receipts`.
|
||||||
|
- Entries list endpoints must not return image bytes.
|
||||||
|
- Image bytes only fetched by separate endpoint when inspecting a single item.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
- Dark mode, minimal, mobile-first.
|
||||||
|
- Dodger Blue accent (#1E90FF).
|
||||||
|
- Top navbar: left nav dropdown, middle group selector, right user menu.
|
||||||
|
|
||||||
|
## Code Rules
|
||||||
|
- Small files, minimal comments.
|
||||||
|
- Prefer single-line `if` without braces when only one line follows.
|
||||||
|
- Heavy logic lives in components/hooks/services, not page files.
|
||||||
|
- Prefer API calls via exported hooks/services for reusability and cleanliness (avoid raw fetch in components when possible).
|
||||||
|
- Add/update unit tests with changes (TDD).
|
||||||
|
- Heavy focus on code readability and maintainability; prioritize clean code over clever code.
|
||||||
|
- ie. Separating different concerns into different files, and adding helper functions to avoid nested logic and improve readability, is preferred over clever one-liners or consolidating logic into fewer files.
|
||||||
|
- ie. Separate groups of related codes by adding 3 line breaks between them
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
- Users (system_role USER|SYS_ADMIN)
|
||||||
|
- Groups + membership (group_role MEMBER|GROUP_ADMIN)
|
||||||
|
- Entries (group-scoped) + optional receipt_id
|
||||||
|
- User settings (jsonb)
|
||||||
|
- Reports for system admins
|
||||||
|
|
||||||
|
## Architecture Contract (Backend ↔ Client ↔ Hooks ↔ UI)
|
||||||
|
|
||||||
|
### No-Assumptions Rule (Required)
|
||||||
|
- Before making structural changes, first scan the repo and identify:
|
||||||
|
- the web app root (where `app/`, `components/`, `hooks/`, `lib/` live)
|
||||||
|
- existing API routes and helpers
|
||||||
|
- existing patterns already in use
|
||||||
|
- Do not invent files, endpoints, or conventions. If something is missing, add it minimally and consistently.
|
||||||
|
|
||||||
|
### Layering (Hard Boundaries)
|
||||||
|
For every domain (auth, groups, entries, receipts, etc.), keep a consistent 4-layer flow:
|
||||||
|
|
||||||
|
1) **API Route Handlers** (`app/api/.../route.ts`)
|
||||||
|
- Thin: parse input, call a server service, return JSON.
|
||||||
|
- No direct DB queries inside route files unless there is no existing server service.
|
||||||
|
- Must enforce auth & membership checks on server.
|
||||||
|
|
||||||
|
2) **Server Services (DB + authorization)** (`lib/server/*`)
|
||||||
|
- Own all DB access and authorization helpers (sessions, requireGroupMember, etc.).
|
||||||
|
- Server-only modules must include `import "server-only";`
|
||||||
|
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/session.ts`.
|
||||||
|
|
||||||
|
3) **Client API Wrappers** (`lib/client/*`)
|
||||||
|
- Typed fetch helpers only (no React state).
|
||||||
|
- Centralize `fetchJson()` / error normalization.
|
||||||
|
- Always send credentials (cookies) and never trust client-side RBAC.
|
||||||
|
|
||||||
|
4) **Hooks (UI-facing API layer)** (`hooks/use-*.ts`)
|
||||||
|
- Hooks are the primary interface for components/pages to call APIs.
|
||||||
|
- Components should not call `fetch()` directly unless there’s a strong reason.
|
||||||
|
|
||||||
|
### Domain Blueprint (Consistency Rule)
|
||||||
|
For any new feature/domain, prefer:
|
||||||
|
- `app/api/<domain>/...`
|
||||||
|
- `lib/server/<domain>.ts`
|
||||||
|
- `lib/client/<domain>.ts`
|
||||||
|
- `hooks/use-<domain>.ts`
|
||||||
|
- `components/<domain>/*`
|
||||||
|
- `__tests__/<domain>.test.ts`
|
||||||
|
|
||||||
|
### API Conventions
|
||||||
|
- Prefer consistent JSON response shape for errors:
|
||||||
|
- `{ error: { code: string, message: string } }`
|
||||||
|
- Validate inputs at the route boundary (basic shape/type), and validate authorization in server services.
|
||||||
|
- When adding endpoints, mirror existing REST style used in the project.
|
||||||
|
|
||||||
|
### Non-Regression Contracts (Do Not Break)
|
||||||
|
- Entries list endpoints must **never** include receipt image bytes; image bytes are fetched via a separate endpoint only.
|
||||||
|
- Auth is DB-backed HttpOnly sessions; all auth checks are server-side.
|
||||||
|
- Groups require server-side membership checks; active group persists per user.
|
||||||
|
- Group invite codes:
|
||||||
|
- shown once immediately after group creation
|
||||||
|
- modal renders outside navbar/header so it overlays the viewport correctly
|
||||||
|
- avoid re-exposing invite code elsewhere without explicit “group settings” work
|
||||||
|
|
||||||
|
### UI Structure
|
||||||
|
- Page files stay thin; heavy logic stays in hooks/services/components.
|
||||||
|
- Dark mode, minimal, mobile-first.
|
||||||
|
- Dodger Blue accent (#1E90FF).
|
||||||
|
- Navbar: left nav dropdown, middle group selector, right user menu.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||||
|
- `DATABASE_URL` points to external Postgres (NOT a container).
|
||||||
|
- `DATABASE_URL` format must be a full connection string; URL-encode special chars in passwords.
|
||||||
|
|
||||||
|
### Tests (Required)
|
||||||
|
- Add/update tests for API behavior changes:
|
||||||
|
- auth
|
||||||
|
- groups
|
||||||
|
- entries (group scoping)
|
||||||
|
- Tests must include negative cases: unauthorized, not-a-member, invalid inputs.
|
||||||
101
.gitignore
vendored
Normal file
101
.gitignore
vendored
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
db.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Dependencies
|
||||||
|
# =========================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Next.js build output
|
||||||
|
# =========================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Production build output (common)
|
||||||
|
# =========================
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Logs
|
||||||
|
# =========================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Runtime / PID files
|
||||||
|
# =========================
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Env files (secrets)
|
||||||
|
# =========================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Local config / caches
|
||||||
|
# =========================
|
||||||
|
.cache/
|
||||||
|
.tmp/
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Testing
|
||||||
|
# =========================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.cypress/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TypeScript
|
||||||
|
# =========================
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Lint / format caches
|
||||||
|
# =========================
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Vercel / Netlify / Deploy
|
||||||
|
# =========================
|
||||||
|
.vercel/
|
||||||
|
.netlify/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OS / Editor
|
||||||
|
# =========================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.js
|
||||||
137
DEBUGGING_INSTRUCTIONS.md
Normal file
137
DEBUGGING_INSTRUCTIONS.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Debug Protocol (Repo)
|
||||||
|
|
||||||
|
> You are debugging an issue in this repo. Do **not** implement features unless required to fix the bug.
|
||||||
|
|
||||||
|
## 0) Non-negotiables
|
||||||
|
|
||||||
|
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If anything conflicts, follow it.
|
||||||
|
- **No assumptions:** First scan the repo for existing patterns, locations, scripts, helpers, and conventions. Don’t invent missing files/endpoints unless truly necessary—and if needed, add minimally and consistently.
|
||||||
|
- **External DB:** `DATABASE_URL` points to on-prem Postgres (**not** a container). Dev/Prod share schema via migrations in `packages/db/migrations`.
|
||||||
|
- **Security:** Never log secrets, full invite codes, or receipt bytes. Invite codes in logs/audit: **last4 only**.
|
||||||
|
- **No cron/worker jobs:** Any fix must work without background tasks.
|
||||||
|
- **Server-side RBAC only:** Client checks are UX only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Restate the bug precisely
|
||||||
|
|
||||||
|
Start by writing a 4–6 line **Bug Definition**:
|
||||||
|
|
||||||
|
- **Expected behavior**
|
||||||
|
- **Actual behavior**
|
||||||
|
- **Where it happens** (page / route / service)
|
||||||
|
- **Impact** (blocker? regression? edge case?)
|
||||||
|
|
||||||
|
If any of these are missing, ask for them explicitly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Collect an evidence bundle before changing code
|
||||||
|
|
||||||
|
Before editing anything, request/locate:
|
||||||
|
|
||||||
|
### A) Runtime signals
|
||||||
|
- Exact error text + stack trace (server + browser console)
|
||||||
|
- Network capture: request URL, method, status, response body
|
||||||
|
- Any `request_id` returned by the API for failing calls
|
||||||
|
|
||||||
|
### B) Repo + environment
|
||||||
|
- Current branch/commit
|
||||||
|
- How app is started (`docker-compose` + which file)
|
||||||
|
- Node version + package manager used
|
||||||
|
- Relevant env vars (sanitized): `DATABASE_URL` **host/port/dbname only** (no password)
|
||||||
|
|
||||||
|
### C) Involved code paths
|
||||||
|
Identify actual files by searching the repo:
|
||||||
|
- The page/component triggering the request
|
||||||
|
- The hook used (`hooks/use-*.ts`)
|
||||||
|
- The client wrapper (`lib/client/*`)
|
||||||
|
- The API route (`app/api/**/route.ts`)
|
||||||
|
- The server service (`lib/server/*.ts`)
|
||||||
|
|
||||||
|
**Do not guess file names. Use repo search.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Determine failure class (choose ONE primary)
|
||||||
|
|
||||||
|
Pick the most likely bucket and focus there first:
|
||||||
|
|
||||||
|
- DB connectivity / docker networking
|
||||||
|
- Migrations/schema mismatch
|
||||||
|
- Auth/session cookie flow (HttpOnly cookies, session table, logout)
|
||||||
|
- RBAC / group membership / active group
|
||||||
|
- Request validation / parsing (route boundary)
|
||||||
|
- Client fetch wrapper / hook state
|
||||||
|
- UI behavior / event handling / mobile UX
|
||||||
|
- Playwright test failure (timing, selectors, baseURL, auth state)
|
||||||
|
|
||||||
|
Write a **3–5 bullet** hypothesis list ordered by likelihood.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Reproduce locally with the smallest surface area
|
||||||
|
|
||||||
|
Prefer reproducing via:
|
||||||
|
1) A single API call (`curl` / minimal `fetch`) before full UI if possible
|
||||||
|
2) Then UI repro
|
||||||
|
3) Then Playwright repro (if it’s a test failure)
|
||||||
|
|
||||||
|
If scripts are needed, inspect `package.json` for actual script names (**don’t assume**). Common ones may exist (`lint`, `test`, `db:migrate`) but confirm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Add targeted observability (minimal + removable)
|
||||||
|
|
||||||
|
If evidence is insufficient, add temporary, minimal logs in **server services** (not in UI):
|
||||||
|
|
||||||
|
- Always include `request_id`
|
||||||
|
- Log only safe metadata (`user_id`, `group_id`, route name, timing)
|
||||||
|
- Never log secrets/full invite code/receipt bytes/passwords/tokens
|
||||||
|
- If touching invite logic: log **last4 only**
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
- One log line at service entry (inputs summary)
|
||||||
|
- One log line at decision points (auth fail / membership fail / db row missing)
|
||||||
|
- One log line at service exit (success + timing)
|
||||||
|
|
||||||
|
Remove or downgrade noisy logs before final PR unless clearly valuable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Fix strategy rules
|
||||||
|
|
||||||
|
- Make the smallest change that resolves the bug.
|
||||||
|
- Respect layering:
|
||||||
|
- **Route:** parse + validate shape
|
||||||
|
- **Server service:** DB + authz + business rules
|
||||||
|
- **Client wrapper:** typed fetch + error normalization
|
||||||
|
- **Hook:** UI-facing API layer
|
||||||
|
- Don’t introduce new dependencies unless absolutely necessary.
|
||||||
|
- Keep touched files free of TS warnings and lint errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Verification checklist (must do)
|
||||||
|
|
||||||
|
After the fix:
|
||||||
|
- Re-run the minimal repro (API and/or UI)
|
||||||
|
- Run relevant tests (unit + Playwright if applicable)
|
||||||
|
- Add/adjust tests for the bug:
|
||||||
|
- At least **1 positive** + **2 negatives** (unauthorized, not-a-member, invalid input)
|
||||||
|
- Confirm contracts:
|
||||||
|
- Spendings list never includes receipt bytes
|
||||||
|
- Sessions are HttpOnly + DB-backed
|
||||||
|
- Audit logs include `request_id` and never store full invite code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Next.js route params checklist (required)
|
||||||
|
|
||||||
|
For `app/api/**/[param]/route.ts`:
|
||||||
|
- Treat `context.params` as **async** and `await` it before reading properties.
|
||||||
|
- If you see errors about sync dynamic APIs, update handlers to unwrap params:
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```ts
|
||||||
|
const { id } = await context.params;
|
||||||
50
PROJECT_INSTRUCTIONS.md
Normal file
50
PROJECT_INSTRUCTIONS.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Project Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
|
## Core expectation
|
||||||
|
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
||||||
|
|
||||||
|
## Decisions / constraints (Group Settings)
|
||||||
|
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
||||||
|
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
|
||||||
|
- Both owner and admins can approve join requests and manage invite links.
|
||||||
|
- Invite links:
|
||||||
|
- TTL limited to 1–7 days.
|
||||||
|
- Settings are immutable after creation (policy, single-use, etc.).
|
||||||
|
- Single-use does not override approval-required.
|
||||||
|
- Expired links are retained and can be revived.
|
||||||
|
- Single-use links are deleted after successful use.
|
||||||
|
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||||
|
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||||
|
- API must generate `request_id` and return it in responses; audit logs must include it.
|
||||||
|
- Audit logs must never store full invite codes (store last4 only).
|
||||||
|
|
||||||
|
## Do first (vertical slice)
|
||||||
|
1) DB migrate command + schema
|
||||||
|
2) Register/Login/Logout (custom sessions)
|
||||||
|
3) Protected dashboard page
|
||||||
|
4) Group create/join + group switcher (approval-based joins + optional join disable)
|
||||||
|
5) Entries CRUD (no receipt bytes in list)
|
||||||
|
6) Receipt upload/download endpoints
|
||||||
|
7) Settings + Reports
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- Works via docker-compose.dev.yml with external DB
|
||||||
|
- Migrations applied via `npm run db:migrate`
|
||||||
|
- Tests + lint pass
|
||||||
|
- RBAC enforced server-side
|
||||||
|
- No large files
|
||||||
|
- No TypeScript warnings or lint errors in touched files
|
||||||
|
- No new cron/worker dependencies unless explicitly approved
|
||||||
|
|
||||||
|
## Desktop + mobile UX checklist (required)
|
||||||
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
|
- Tap targets remain >= 40px on mobile.
|
||||||
|
- Modal overlays must close on outside click/tap.
|
||||||
|
- Use bubble notifications for main actions (create/update/delete/join).
|
||||||
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||||
|
|
||||||
|
## PR review checklist
|
||||||
|
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
|
||||||
|
- No TypeScript warnings or lint errors introduced.
|
||||||
22
README.md
Normal file
22
README.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Fiddy (External DB)
|
||||||
|
|
||||||
|
Monorepo scaffold for a budgeting/collaboration app that connects to an external on-prem Postgres server.
|
||||||
|
|
||||||
|
## Quick start (dev)
|
||||||
|
1. Copy env template:
|
||||||
|
- `.env.example` -> `apps/web/.env`
|
||||||
|
2. Edit `apps/web/.env`:
|
||||||
|
- Set `DATABASE_URL` to your DB server hostname/IP (NOT `db`).
|
||||||
|
- Set `ALLOWED_DB_NAMES` to a comma-separated allowlist (case-insensitive).
|
||||||
|
3. Start web:
|
||||||
|
- `docker compose -f docker-compose.dev.yml up --build`
|
||||||
|
4. Apply migrations:
|
||||||
|
- `npm run db:migrate` (ensure `DATABASE_URL` points to the DB you want)
|
||||||
|
|
||||||
|
## Prod
|
||||||
|
- Deploy folder: `/opt/fiddy`
|
||||||
|
- Put `.env` in `/opt/fiddy/apps/web/.env` with the prod DB connection string and `ALLOWED_DB_NAMES`.
|
||||||
|
- `docker compose up -d` will run only the web container; DB is external.
|
||||||
|
|
||||||
|
## Copilot
|
||||||
|
- See `copilot-instructions.md` and `PROJECT_INSTRUCTIONS.md`
|
||||||
130
apps/web/__tests__/auth.test.ts
Normal file
130
apps/web/__tests__/auth.test.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { createSession, hashPassword, verifyPassword } from "../lib/server/auth";
|
||||||
|
import { loginUser, registerUser } from "../lib/server/auth-service";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { cleanupTestData, cleanupTestDataFromPool } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("password hashing works", async () => {
|
||||||
|
const hash = await hashPassword("test-password");
|
||||||
|
assert.ok(await verifyPassword("test-password", hash));
|
||||||
|
assert.ok(!(await verifyPassword("wrong", hash)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createSession inserts row", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error)
|
||||||
|
t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
const email = `test_${Date.now()}@example.com`;
|
||||||
|
try {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[email, await hashPassword("test-password")]
|
||||||
|
);
|
||||||
|
const userId = rows[0].id as number;
|
||||||
|
const session = await createSession(userId);
|
||||||
|
const { rows: sessionRows } = await client.query(
|
||||||
|
"select id from sessions where user_id=$1 and expires_at > now()",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
assert.ok(session.token.length > 10);
|
||||||
|
assert.ok(sessionRows.length === 1);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { emails: [email] });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createSession supports ttl override", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error)
|
||||||
|
t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
const email = `test_${Date.now()}_ttl@example.com`;
|
||||||
|
const ttlMs = 60 * 60 * 1000;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
try {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[email, await hashPassword("test-password")]
|
||||||
|
);
|
||||||
|
const userId = rows[0].id as number;
|
||||||
|
const session = await createSession(userId, { ttlMs });
|
||||||
|
const { rows: sessionRows } = await client.query(
|
||||||
|
"select expires_at from sessions where user_id=$1 order by created_at desc limit 1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const dbExpiresAt = new Date(sessionRows[0].expires_at).getTime();
|
||||||
|
const expectedMin = nowMs + ttlMs - 5000;
|
||||||
|
const expectedMax = nowMs + ttlMs + 5000;
|
||||||
|
assert.ok(session.expiresAt.getTime() >= expectedMin);
|
||||||
|
assert.ok(session.expiresAt.getTime() <= expectedMax);
|
||||||
|
assert.ok(dbExpiresAt >= expectedMin);
|
||||||
|
assert.ok(dbExpiresAt <= expectedMax);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { emails: [email] });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loginUser respects remember flag", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const email = `remember_${Date.now()}@example.com`;
|
||||||
|
const password = "test-password";
|
||||||
|
const result = await registerUser({ email, password, displayName: "" });
|
||||||
|
try {
|
||||||
|
const rememberTrue = await loginUser({ email, password, remember: true });
|
||||||
|
const rememberFalse = await loginUser({ email, password, remember: false });
|
||||||
|
assert.ok(rememberTrue.session.ttlMs > rememberFalse.session.ttlMs);
|
||||||
|
} finally {
|
||||||
|
const pool = getPool();
|
||||||
|
await cleanupTestDataFromPool(pool, { userIds: [result.user.id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registerUser rejects duplicate email", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const email = `dup_${Date.now()}@example.com`;
|
||||||
|
const password = "test-password";
|
||||||
|
const mixedCase = email.toUpperCase();
|
||||||
|
const result = await registerUser({ email, password, displayName: "" });
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => registerUser({ email: mixedCase, password, displayName: "" }),
|
||||||
|
{ message: "EMAIL_EXISTS" }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestDataFromPool(pool, { userIds: [result.user.id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
65
apps/web/__tests__/bucket-usage.test.ts
Normal file
65
apps/web/__tests__/bucket-usage.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { calculateBucketUsage } from "../lib/shared/bucket-usage";
|
||||||
|
|
||||||
|
const today = "2026-02-11";
|
||||||
|
|
||||||
|
test("calculateBucketUsage matches tag subset", () => {
|
||||||
|
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 };
|
||||||
|
const entries = [
|
||||||
|
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
|
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries"], isRecurring: false, entryType: "SPENDING" as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
assert.equal(result.totalUsage, 20);
|
||||||
|
assert.equal(result.matchedCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateBucketUsage excludes recurring entries", () => {
|
||||||
|
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
|
||||||
|
const entries = [
|
||||||
|
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
assert.equal(result.totalUsage, 0);
|
||||||
|
assert.equal(result.matchedCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateBucketUsage applies windowDays filtering", () => {
|
||||||
|
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
|
||||||
|
const entries = [
|
||||||
|
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
|
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
|
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
assert.equal(result.totalUsage, 20);
|
||||||
|
assert.equal(result.matchedCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
||||||
|
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
|
||||||
|
const entries = [
|
||||||
|
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
|
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
assert.equal(result.totalUsage, 20);
|
||||||
|
assert.equal(result.matchedCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
||||||
|
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
|
||||||
|
const entries = [
|
||||||
|
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
|
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
assert.equal(result.totalUsage, 5);
|
||||||
|
assert.equal(result.matchedCount, 1);
|
||||||
|
});
|
||||||
85
apps/web/__tests__/buckets.test.ts
Normal file
85
apps/web/__tests__/buckets.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { createBucket, deleteBucket, listBuckets, updateBucket } from "../lib/server/buckets";
|
||||||
|
import { ensureTagsForGroup } from "../lib/server/tags";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("buckets CRUD", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let userId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`bucket_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = userRes.rows[0].id as number;
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Buckets Test", uniqueInviteCode("B"), userId]
|
||||||
|
);
|
||||||
|
groupId = groupRes.rows[0].id as number;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] });
|
||||||
|
|
||||||
|
const bucket = await createBucket({
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
name: "Groceries",
|
||||||
|
description: "Weekly groceries",
|
||||||
|
iconKey: "food",
|
||||||
|
budgetLimitDollars: 200,
|
||||||
|
tags: ["groceries", "weekly"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await listBuckets(groupId);
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0].id, bucket.id);
|
||||||
|
assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]);
|
||||||
|
|
||||||
|
const updated = await updateBucket({
|
||||||
|
id: bucket.id,
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
name: "Groceries+",
|
||||||
|
description: "Updated",
|
||||||
|
iconKey: "food",
|
||||||
|
budgetLimitDollars: 250,
|
||||||
|
tags: ["groceries"]
|
||||||
|
});
|
||||||
|
assert.ok(updated);
|
||||||
|
assert.equal(updated?.name, "Groceries+");
|
||||||
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
|
await deleteBucket({ id: bucket.id, groupId });
|
||||||
|
const listAfter = await listBuckets(groupId);
|
||||||
|
assert.equal(listAfter.length, 0);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
5
apps/web/__tests__/entries.test.ts
Normal file
5
apps/web/__tests__/entries.test.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
test("entries CRUD (covered by legacy test file)", async t => {
|
||||||
|
t.skip("Covered by spendings.test.ts after pivot");
|
||||||
|
});
|
||||||
149
apps/web/__tests__/group-ownership.test.ts
Normal file
149
apps/web/__tests__/group-ownership.test.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { joinGroup, requireActiveGroup } from "../lib/server/groups";
|
||||||
|
import { setGroupSettings } from "../lib/server/group-settings";
|
||||||
|
import { transferOwnership } from "../lib/server/group-members";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("join policy enforcement and join requests", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
const inviteCode = uniqueInviteCode("J");
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`owner_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`member_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Join Policy Group", inviteCode, ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" });
|
||||||
|
await assert.rejects(
|
||||||
|
() => joinGroup(memberId!, inviteCode),
|
||||||
|
{ message: "JOIN_NOT_ACCEPTING" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" });
|
||||||
|
await assert.rejects(
|
||||||
|
() => joinGroup(memberId!, inviteCode),
|
||||||
|
{ message: "JOIN_PENDING" }
|
||||||
|
);
|
||||||
|
const pendingRes = await client.query(
|
||||||
|
"select status from group_join_requests where group_id=$1 and user_id=$2",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
assert.equal(pendingRes.rows[0]?.status, "PENDING");
|
||||||
|
|
||||||
|
await client.query("delete from group_join_requests where group_id=$1 and user_id=$2", [groupId, memberId]);
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "AUTO_ACCEPT" });
|
||||||
|
const group = await joinGroup(memberId!, inviteCode);
|
||||||
|
assert.equal(Number(group.id), groupId);
|
||||||
|
|
||||||
|
const memberRows = await client.query(
|
||||||
|
"select role from group_members where group_id=$1 and user_id=$2",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
assert.equal(memberRows.rows[0]?.role, "MEMBER");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ownership transfer updates roles", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`owner2_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`member2_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Ownership Group", uniqueInviteCode("O"), ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await transferOwnership({
|
||||||
|
actorUserId: ownerId,
|
||||||
|
groupId,
|
||||||
|
newOwnerUserId: memberId,
|
||||||
|
requestId: `req_${Date.now()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = await client.query(
|
||||||
|
"select user_id, role from group_members where group_id=$1",
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
const roleMap = new Map(roles.rows.map(row => [Number(row.user_id), row.role]));
|
||||||
|
assert.equal(roleMap.get(ownerId), "GROUP_ADMIN");
|
||||||
|
assert.equal(roleMap.get(memberId), "GROUP_OWNER");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
118
apps/web/__tests__/group-settings.test.ts
Normal file
118
apps/web/__tests__/group-settings.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("group settings update and read", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`settings_owner_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Settings Group", uniqueInviteCode("S"), ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initial = await getGroupSettings(groupId);
|
||||||
|
assert.equal(initial.allowMemberTagManage, false);
|
||||||
|
assert.equal(initial.joinPolicy, "NOT_ACCEPTING");
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" });
|
||||||
|
const updated = await getGroupSettings(groupId);
|
||||||
|
assert.equal(updated.allowMemberTagManage, true);
|
||||||
|
assert.equal(updated.joinPolicy, "AUTO_ACCEPT");
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" });
|
||||||
|
const updatedAgain = await getGroupSettings(groupId);
|
||||||
|
assert.equal(updatedAgain.allowMemberTagManage, false);
|
||||||
|
assert.equal(updatedAgain.joinPolicy, "APPROVAL_REQUIRED");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("group settings require admin", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`settings_owner_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`settings_member_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Settings Perms", uniqueInviteCode("P"), ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||||
|
{ message: "FORBIDDEN" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = await getGroupSettings(groupId);
|
||||||
|
assert.equal(settings.allowMemberTagManage, false);
|
||||||
|
assert.equal(settings.joinPolicy, "NOT_ACCEPTING");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
109
apps/web/__tests__/groups.test.ts
Normal file
109
apps/web/__tests__/groups.test.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { setActiveGroupForUser } from "../lib/server/groups";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("create group inserts membership", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let userId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`group_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = Number(userRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Test Group", uniqueInviteCode("G"), userId]
|
||||||
|
);
|
||||||
|
groupId = groupRes.rows[0].id as number;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
"select role from group_members where group_id=$1 and user_id=$2",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].role, "GROUP_OWNER");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setActiveGroupForUser stores active group", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let userId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
let otherUserId: number | null = null;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`active_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = userRes.rows[0].id as number;
|
||||||
|
|
||||||
|
const otherRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`active_other_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
otherUserId = Number(otherRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Active Group", uniqueInviteCode("A"), userId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await setActiveGroupForUser(userId, groupId);
|
||||||
|
const { rows } = await client.query(
|
||||||
|
"select data->>'activeGroupId' as active_group_id from user_settings where user_id=$1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
assert.equal(Number(rows[0]?.active_group_id || 0), groupId);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => setActiveGroupForUser(otherUserId!, groupId!),
|
||||||
|
{ message: "FORBIDDEN" }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [otherUserId, userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
128
apps/web/__tests__/invite-links.test.ts
Normal file
128
apps/web/__tests__/invite-links.test.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { acceptInviteLink, createInviteLink } from "../lib/server/group-invites";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("invite link auto-accept adds member", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`invite_owner_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`invite_member_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Invite Group", uniqueInviteCode("I"), ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = await createInviteLink({
|
||||||
|
userId: ownerId,
|
||||||
|
groupId,
|
||||||
|
policy: "AUTO_ACCEPT",
|
||||||
|
singleUse: false,
|
||||||
|
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||||
|
requestId: "test-request"
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
||||||
|
assert.equal(result.status, "JOINED");
|
||||||
|
|
||||||
|
const { rowCount } = await client.query(
|
||||||
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
assert.equal(rowCount, 1);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invite link rejects expired link", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let ownerId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const ownerRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`invite_owner_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
ownerId = Number(ownerRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`invite_member_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Invite Group", uniqueInviteCode("E"), ownerId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = await createInviteLink({
|
||||||
|
userId: ownerId,
|
||||||
|
groupId,
|
||||||
|
policy: "AUTO_ACCEPT",
|
||||||
|
singleUse: false,
|
||||||
|
expiresAt: new Date(Date.now() - 60 * 1000),
|
||||||
|
requestId: "test-request"
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" }),
|
||||||
|
{ message: "INVITE_EXPIRED" }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
77
apps/web/__tests__/recurring-entries.test.ts
Normal file
77
apps/web/__tests__/recurring-entries.test.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { createRecurringEntry, deleteRecurringEntry, listRecurringEntries } from "../lib/server/recurring-entries";
|
||||||
|
import { ensureTagsForGroup } from "../lib/server/tags";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("recurring entries list", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let userId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`recurring_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = userRes.rows[0].id as number;
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Recurring Test", uniqueInviteCode("R"), userId]
|
||||||
|
);
|
||||||
|
groupId = groupRes.rows[0].id as number;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTagsForGroup({ userId, groupId, tags: ["rent"] });
|
||||||
|
|
||||||
|
await createRecurringEntry({
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
entryType: "SPENDING",
|
||||||
|
amountDollars: 900,
|
||||||
|
occurredAt: "2026-02-01",
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
purchaseType: "Rent",
|
||||||
|
notes: "Monthly rent",
|
||||||
|
tags: ["rent"],
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
nextRunAt: "2026-02-01"
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await listRecurringEntries(groupId);
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0].isRecurring, true);
|
||||||
|
} finally {
|
||||||
|
if (groupId) {
|
||||||
|
const list = await listRecurringEntries(groupId);
|
||||||
|
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
|
||||||
|
}
|
||||||
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
67
apps/web/__tests__/run-tests.cjs
Normal file
67
apps/web/__tests__/run-tests.cjs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// __tests__/run-tests.cjs
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(full));
|
||||||
|
else if (entry.isFile() && entry.name.endsWith(".test.ts")) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
dotenv.config({ path: path.join(cwd, ".env") });
|
||||||
|
const testsDir = path.join(cwd, "__tests__");
|
||||||
|
const testFiles = fs.existsSync(testsDir) ? walk(testsDir) : [];
|
||||||
|
|
||||||
|
const inferredAllowedDbNames = (() => {
|
||||||
|
if (process.env.ALLOWED_DB_NAMES) return process.env.ALLOWED_DB_NAMES;
|
||||||
|
if (!process.env.DATABASE_URL) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
const name = url.pathname.replace(/^\/+/, "");
|
||||||
|
return name ? decodeURIComponent(name) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (testFiles.length === 0) {
|
||||||
|
console.error("No .test.ts files found under __tests__/");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run Node's test runner, with tsx registered so TS can execute.
|
||||||
|
// Node supports registering tsx via --import=tsx. :contentReference[oaicite:2]{index=2}
|
||||||
|
const nodeArgs = [
|
||||||
|
"--require",
|
||||||
|
"./__tests__/server-only.cjs",
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
"--test-concurrency=1",
|
||||||
|
"--test",
|
||||||
|
// Optional nicer output:
|
||||||
|
// "--test-reporter=spec",
|
||||||
|
...testFiles,
|
||||||
|
];
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: "test",
|
||||||
|
NODE_PATH: path.join(cwd, "__tests__", "node_modules"),
|
||||||
|
...(inferredAllowedDbNames ? { ALLOWED_DB_NAMES: inferredAllowedDbNames } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = spawnSync(process.execPath, nodeArgs, {
|
||||||
|
stdio: "inherit",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(r.status ?? 1);
|
||||||
18
apps/web/__tests__/server-only-loader.mjs
Normal file
18
apps/web/__tests__/server-only-loader.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export async function resolve(specifier, context, defaultResolve) {
|
||||||
|
if (specifier === "server-only")
|
||||||
|
return {
|
||||||
|
url: "data:text/javascript,export default {}",
|
||||||
|
shortCircuit: true,
|
||||||
|
};
|
||||||
|
return defaultResolve(specifier, context, defaultResolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load(url, context, defaultLoad) {
|
||||||
|
if (url.startsWith("data:text/javascript"))
|
||||||
|
return {
|
||||||
|
format: "module",
|
||||||
|
source: "export default {}",
|
||||||
|
shortCircuit: true,
|
||||||
|
};
|
||||||
|
return defaultLoad(url, context, defaultLoad);
|
||||||
|
}
|
||||||
1
apps/web/__tests__/server-only-stub.js
Normal file
1
apps/web/__tests__/server-only-stub.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
17
apps/web/__tests__/server-only.cjs
Normal file
17
apps/web/__tests__/server-only.cjs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const Module = require("module");
|
||||||
|
const path = require("path");
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const originalResolve = Module._resolveFilename;
|
||||||
|
const stubPath = path.join(__dirname, "server-only-stub.js");
|
||||||
|
|
||||||
|
Module._resolveFilename = function (request, parent, isMain, options) {
|
||||||
|
if (request === "server-only" || request.startsWith("server-only/"))
|
||||||
|
return stubPath;
|
||||||
|
return originalResolve.call(this, request, parent, isMain, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
Module._load = function (request, parent, isMain) {
|
||||||
|
if (request === "server-only" || request.startsWith("server-only/"))
|
||||||
|
return {};
|
||||||
|
return originalLoad(request, parent, isMain);
|
||||||
|
};
|
||||||
98
apps/web/__tests__/spendings.test.ts
Normal file
98
apps/web/__tests__/spendings.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { createEntry, deleteEntry, listEntries, updateEntry } from "../lib/server/entries";
|
||||||
|
import { ensureTagsForGroup } from "../lib/server/tags";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("entries CRUD", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let userId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`entry_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = userRes.rows[0].id as number;
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Entries Test", uniqueInviteCode("E"), userId]
|
||||||
|
);
|
||||||
|
groupId = groupRes.rows[0].id as number;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] });
|
||||||
|
|
||||||
|
const entry = await createEntry({
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
entryType: "SPENDING",
|
||||||
|
amountDollars: 12.34,
|
||||||
|
occurredAt: "2026-02-02",
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
purchaseType: "Groceries",
|
||||||
|
notes: "Test",
|
||||||
|
tags: ["groceries", "weekly"],
|
||||||
|
isRecurring: false,
|
||||||
|
intervalCount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await listEntries(groupId);
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0].id, entry.id);
|
||||||
|
assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]);
|
||||||
|
assert.equal(list[0].entryType, "SPENDING");
|
||||||
|
|
||||||
|
const updated = await updateEntry({
|
||||||
|
id: entry.id,
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
entryType: "INCOME",
|
||||||
|
amountDollars: 15,
|
||||||
|
occurredAt: "2026-02-02",
|
||||||
|
necessity: "BOTH",
|
||||||
|
purchaseType: "Groceries",
|
||||||
|
notes: "Updated",
|
||||||
|
tags: ["groceries"],
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
nextRunAt: "2026-02-02"
|
||||||
|
});
|
||||||
|
assert.ok(updated);
|
||||||
|
assert.equal(updated?.amountDollars, 15);
|
||||||
|
assert.equal(updated?.entryType, "INCOME");
|
||||||
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
|
await deleteEntry({ id: entry.id, groupId });
|
||||||
|
const listAfter = await listEntries(groupId);
|
||||||
|
assert.equal(listAfter.length, 0);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
81
apps/web/__tests__/tags.test.ts
Normal file
81
apps/web/__tests__/tags.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import getPool from "../lib/server/db";
|
||||||
|
import { deleteTagForGroup, ensureTagsForGroup, listGroupTags } from "../lib/server/tags";
|
||||||
|
import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings";
|
||||||
|
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
|
||||||
|
|
||||||
|
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
test("group tag permissions and management", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
let adminId: number | null = null;
|
||||||
|
let memberId: number | null = null;
|
||||||
|
let groupId: number | null = null;
|
||||||
|
try {
|
||||||
|
const adminRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`tags_admin_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
adminId = Number(adminRes.rows[0].id);
|
||||||
|
|
||||||
|
const memberRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`tags_member_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
memberId = Number(memberRes.rows[0].id);
|
||||||
|
|
||||||
|
const groupRes = await client.query(
|
||||||
|
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
|
||||||
|
["Tags Group", uniqueInviteCode("T"), adminId]
|
||||||
|
);
|
||||||
|
groupId = Number(groupRes.rows[0].id);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
|
||||||
|
[groupId, adminId]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
|
||||||
|
[groupId, memberId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = await getGroupSettings(groupId);
|
||||||
|
assert.equal(settings.allowMemberTagManage, false);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food"] }),
|
||||||
|
{ message: "FORBIDDEN" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await setGroupSettings({ userId: adminId, groupId, allowMemberTagManage: true });
|
||||||
|
const settingsAfter = await getGroupSettings(groupId);
|
||||||
|
assert.equal(settingsAfter.allowMemberTagManage, true);
|
||||||
|
|
||||||
|
await ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food", "dining"] });
|
||||||
|
const tags = await listGroupTags(groupId);
|
||||||
|
assert.deepEqual(tags, ["dining", "food"]);
|
||||||
|
|
||||||
|
await deleteTagForGroup({ userId: memberId!, groupId: groupId!, name: "food" });
|
||||||
|
const afterDelete = await listGroupTags(groupId);
|
||||||
|
assert.deepEqual(afterDelete, ["dining"]);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [adminId, memberId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
67
apps/web/__tests__/test-helpers.ts
Normal file
67
apps/web/__tests__/test-helpers.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import type { Pool, PoolClient } from "pg";
|
||||||
|
|
||||||
|
type CleanupArgs = {
|
||||||
|
groupId?: number | null;
|
||||||
|
userIds?: Array<number | null | undefined>;
|
||||||
|
emails?: Array<string | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function uniqueInviteCode(prefix = "T"): string {
|
||||||
|
const raw = `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||||
|
const cleaned = raw.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
if (cleaned.length >= 8) return cleaned.slice(0, 8);
|
||||||
|
return cleaned.padEnd(8, "X");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestData(client: PoolClient, args: CleanupArgs) {
|
||||||
|
const groupId = args.groupId ?? null;
|
||||||
|
const userIds = (args.userIds || []).filter((id): id is number => Boolean(id));
|
||||||
|
const emails = (args.emails || []).filter((email): email is string => Boolean(email));
|
||||||
|
|
||||||
|
const safeQuery = async (text: string, params: Array<unknown>) => {
|
||||||
|
try {
|
||||||
|
await client.query(text, params);
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === "object" && error && "code" in error && error.code === "42P01") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
await safeQuery(
|
||||||
|
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
await safeQuery("delete from bucket_tags where bucket_id in (select id from buckets where group_id=$1)", [groupId]);
|
||||||
|
await safeQuery("delete from buckets where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from tags where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from group_audit_log where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from group_invite_links where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from group_join_requests where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from group_settings where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from group_members where group_id=$1", [groupId]);
|
||||||
|
await safeQuery("delete from groups where id=$1", [groupId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await safeQuery("delete from user_settings where user_id=$1", [userId]);
|
||||||
|
await safeQuery("delete from sessions where user_id=$1", [userId]);
|
||||||
|
await safeQuery("delete from users where id=$1", [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
await safeQuery("delete from sessions where user_id in (select id from users where email=$1)", [email]);
|
||||||
|
await safeQuery("delete from users where email=$1", [email]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await cleanupTestData(client, args);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
apps/web/app/api/auth/login/route.ts
Normal file
36
apps/web/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
|
import { loginUser } from "@/lib/server/auth-service";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
|
const password = String(body?.password || "");
|
||||||
|
const remember = Boolean(body?.remember ?? true);
|
||||||
|
|
||||||
|
if (!email || !password)
|
||||||
|
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
||||||
|
|
||||||
|
let user;
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
const result = await loginUser({ email, password, remember });
|
||||||
|
user = result.user;
|
||||||
|
session = result.session;
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(getSessionCookieName(), session.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: Math.floor(session.ttlMs / 1000),
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
20
apps/web/app/api/auth/logout/route.ts
Normal file
20
apps/web/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
|
import { logoutUser } from "@/lib/server/auth-service";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||||
|
if (token)
|
||||||
|
await logoutUser(token);
|
||||||
|
cookieStore.set(getSessionCookieName(), "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 0,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
9
apps/web/app/api/auth/me/route.ts
Normal file
9
apps/web/app/api/auth/me/route.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user)
|
||||||
|
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
38
apps/web/app/api/auth/register/route.ts
Normal file
38
apps/web/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
||||||
|
import { registerUser } from "@/lib/server/auth-service";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
|
const password = String(body?.password || "");
|
||||||
|
const displayName = String(body?.displayName || "").trim();
|
||||||
|
|
||||||
|
if (!email || !email.includes("@"))
|
||||||
|
return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
||||||
|
if (password.length < 8)
|
||||||
|
return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
||||||
|
|
||||||
|
let user;
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
const result = await registerUser({ email, password, displayName });
|
||||||
|
user = result.user;
|
||||||
|
session = result.session;
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(getSessionCookieName(), session.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: Math.floor(getSessionTtlMs() / 1000),
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
78
apps/web/app/api/buckets/[id]/route.ts
Normal file
78
apps/web/app/api/buckets/[id]/route.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { deleteBucket, requireActiveGroup, updateBucket } from "@/lib/server/buckets";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const name = String(body?.name || "").trim();
|
||||||
|
const description = String(body?.description || "").trim();
|
||||||
|
const iconKey = body?.iconKey ? String(body.iconKey) : null;
|
||||||
|
const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null;
|
||||||
|
const position = body?.position != null ? Number(body.position) : 0;
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||||
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
|
if (!name)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
|
const bucket = await updateBucket({
|
||||||
|
id,
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
iconKey,
|
||||||
|
budgetLimitDollars,
|
||||||
|
position,
|
||||||
|
tags,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
windowDays
|
||||||
|
});
|
||||||
|
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, bucket });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
await deleteBucket({ id, groupId });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/web/app/api/buckets/route.ts
Normal file
67
apps/web/app/api/buckets/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const buckets = await listBuckets(groupId);
|
||||||
|
return NextResponse.json({ requestId, buckets });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const name = String(body?.name || "").trim();
|
||||||
|
const description = String(body?.description || "").trim();
|
||||||
|
const iconKey = body?.iconKey ? String(body.iconKey) : null;
|
||||||
|
const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null;
|
||||||
|
const position = body?.position != null ? Number(body.position) : 0;
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||||
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
|
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
|
const bucket = await createBucket({
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
iconKey,
|
||||||
|
budgetLimitDollars,
|
||||||
|
position,
|
||||||
|
tags,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
windowDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, bucket });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
99
apps/web/app/api/entries/[id]/route.ts
Normal file
99
apps/web/app/api/entries/[id]/route.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup, updateEntry, deleteEntry } from "@/lib/server/entries";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
|
const occurredAt = String(body?.occurredAt || "");
|
||||||
|
const necessity = String(body?.necessity || "");
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
|
const notes = String(body?.notes || "").trim();
|
||||||
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const isRecurring = Boolean(body?.isRecurring);
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const entry = await updateEntry({
|
||||||
|
id,
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
occurredAt,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
isRecurring,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, entry });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
await deleteEntry({ id, groupId });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
88
apps/web/app/api/entries/route.ts
Normal file
88
apps/web/app/api/entries/route.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const entries = await listEntries(groupId);
|
||||||
|
return NextResponse.json({ requestId, entries });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
|
const occurredAt = String(body?.occurredAt || "");
|
||||||
|
const necessity = String(body?.necessity || "");
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
|
const notes = String(body?.notes || "").trim();
|
||||||
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const isRecurring = Boolean(body?.isRecurring);
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const entry = await createEntry({
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
occurredAt,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
isRecurring,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, entry });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/web/app/api/groups/active/route.ts
Normal file
35
apps/web/app/api/groups/active/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groups = await listGroups(user.id);
|
||||||
|
const activeGroupId = await getActiveGroupId(user.id);
|
||||||
|
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
||||||
|
return NextResponse.json({ requestId, active });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const groupId = Number(body?.groupId || 0);
|
||||||
|
if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
||||||
|
|
||||||
|
await setActiveGroupForUser(user.id, groupId);
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/web/app/api/groups/audit/route.ts
Normal file
19
apps/web/app/api/groups/audit/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { listGroupAudit } from "@/lib/server/group-audit";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const events = await listGroupAudit({ userId: user.id, groupId });
|
||||||
|
return NextResponse.json({ requestId, events });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/web/app/api/groups/delete/route.ts
Normal file
18
apps/web/app/api/groups/delete/route.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/invites/delete/route.ts
Normal file
23
apps/web/app/api/groups/invites/delete/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { deleteInviteLink } from "@/lib/server/group-invites";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const linkId = Number(body?.linkId || 0);
|
||||||
|
if (!linkId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
|
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/web/app/api/groups/invites/revive/route.ts
Normal file
27
apps/web/app/api/groups/invites/revive/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { reviveInviteLink } from "@/lib/server/group-invites";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const linkId = Number(body?.linkId || 0);
|
||||||
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
|
if (!linkId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
|
if (!ttlDays)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
|
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/invites/revoke/route.ts
Normal file
23
apps/web/app/api/groups/invites/revoke/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { revokeInviteLink } from "@/lib/server/group-invites";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const linkId = Number(body?.linkId || 0);
|
||||||
|
if (!linkId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
|
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/web/app/api/groups/invites/route.ts
Normal file
41
apps/web/app/api/groups/invites/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const links = await listInviteLinks({ userId: user.id, groupId });
|
||||||
|
return NextResponse.json({ requestId, links });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const policy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.policy))
|
||||||
|
? (String(body?.policy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")
|
||||||
|
: "NOT_ACCEPTING";
|
||||||
|
const singleUse = Boolean(body?.singleUse);
|
||||||
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
|
if (!ttlDays)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
|
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, link });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/web/app/api/groups/join/route.ts
Normal file
22
apps/web/app/api/groups/join/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { joinGroup } from "@/lib/server/groups";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
||||||
|
if (!inviteCode)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
||||||
|
|
||||||
|
const group = await joinGroup(user.id, inviteCode);
|
||||||
|
return NextResponse.json({ requestId, group });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/app/api/groups/members/approve/route.ts
Normal file
24
apps/web/app/api/groups/members/approve/route.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { approveJoinRequest } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
const joinRequestId = Number(body?.requestId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/members/demote/route.ts
Normal file
23
apps/web/app/api/groups/members/demote/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { demoteAdmin } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/members/deny/route.ts
Normal file
23
apps/web/app/api/groups/members/deny/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { denyJoinRequest } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/members/kick/route.ts
Normal file
23
apps/web/app/api/groups/members/kick/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { kickMember } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/web/app/api/groups/members/leave/route.ts
Normal file
19
apps/web/app/api/groups/members/leave/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { leaveGroup } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/members/promote/route.ts
Normal file
23
apps/web/app/api/groups/members/promote/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { promoteToAdmin } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/web/app/api/groups/members/route.ts
Normal file
20
apps/web/app/api/groups/members/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const members = await listGroupMembers(groupId);
|
||||||
|
const requests = await listJoinRequests({ userId: user.id, groupId });
|
||||||
|
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/app/api/groups/members/transfer-owner/route.ts
Normal file
23
apps/web/app/api/groups/members/transfer-owner/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { transferOwnership } from "@/lib/server/group-members";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const userId = Number(body?.userId || 0);
|
||||||
|
if (!userId)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
|
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/web/app/api/groups/rename/route.ts
Normal file
22
apps/web/app/api/groups/rename/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const name = String(body?.name || "").trim();
|
||||||
|
if (!name)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
|
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/web/app/api/groups/route.ts
Normal file
33
apps/web/app/api/groups/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { createGroup, listGroups } from "@/lib/server/groups";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groups = await listGroups(user.id);
|
||||||
|
return NextResponse.json({ requestId, groups });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const name = String(body?.name || "").trim();
|
||||||
|
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
|
|
||||||
|
const group = await createGroup(user.id, name);
|
||||||
|
return NextResponse.json({ requestId, group });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/web/app/api/groups/settings/route.ts
Normal file
38
apps/web/app/api/groups/settings/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const settings = await getGroupSettings(groupId);
|
||||||
|
return NextResponse.json({ requestId, settings });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const allowMemberTagManage = Boolean(body?.allowMemberTagManage);
|
||||||
|
const joinPolicy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.joinPolicy))
|
||||||
|
? (String(body?.joinPolicy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")
|
||||||
|
: "NOT_ACCEPTING";
|
||||||
|
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
||||||
|
const settings = await getGroupSettings(groupId);
|
||||||
|
return NextResponse.json({ requestId, settings });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/web/app/api/invite-links/[token]/route.ts
Normal file
41
apps/web/app/api/invite-links/[token]/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { apiError, toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
||||||
|
|
||||||
|
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const { token } = await context.params;
|
||||||
|
const normalized = String(token || "").trim();
|
||||||
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
|
const link = await getInviteLinkSummaryByToken(normalized);
|
||||||
|
if (!link) apiError("INVITE_NOT_FOUND", { tokenLast4: normalized.slice(-4) });
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (user) {
|
||||||
|
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
||||||
|
if (viewerStatus)
|
||||||
|
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ requestId, link });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const { token } = await context.params;
|
||||||
|
const normalized = String(token || "").trim();
|
||||||
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
|
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
||||||
|
return NextResponse.json({ requestId, result });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/web/app/api/recurring-entries/[id]/route.ts
Normal file
98
apps/web/app/api/recurring-entries/[id]/route.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { deleteRecurringEntry, requireActiveGroup, updateRecurringEntry } from "@/lib/server/recurring-entries";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
|
const occurredAt = String(body?.occurredAt || "");
|
||||||
|
const necessity = String(body?.necessity || "");
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
|
const notes = String(body?.notes || "").trim();
|
||||||
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const entry = await updateRecurringEntry({
|
||||||
|
id,
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
occurredAt,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, entry });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = Number(idParam || 0);
|
||||||
|
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
await deleteRecurringEntry({ id, groupId });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/web/app/api/recurring-entries/route.ts
Normal file
87
apps/web/app/api/recurring-entries/route.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
function parseTags(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.map(tag => String(tag));
|
||||||
|
if (typeof value === "string") return value.split(",");
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const entries = await listRecurringEntries(groupId);
|
||||||
|
return NextResponse.json({ requestId, entries });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
|
const occurredAt = String(body?.occurredAt || "");
|
||||||
|
const necessity = String(body?.necessity || "");
|
||||||
|
const tags = parseTags(body?.tags);
|
||||||
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
|
const notes = String(body?.notes || "").trim();
|
||||||
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const entry = await createRecurringEntry({
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
occurredAt,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, entry });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/web/app/api/tags/[name]/route.ts
Normal file
20
apps/web/app/api/tags/[name]/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/entries";
|
||||||
|
import { deleteTagForGroup } from "@/lib/server/tags";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const { name } = await params;
|
||||||
|
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
||||||
|
return NextResponse.json({ requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/web/app/api/tags/route.ts
Normal file
35
apps/web/app/api/tags/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/entries";
|
||||||
|
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const tags = await listGroupTags(groupId);
|
||||||
|
return NextResponse.json({ requestId, tags });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
||||||
|
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
||||||
|
const list = await listGroupTags(groupId);
|
||||||
|
return NextResponse.json({ requestId, tags: list });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/web/app/favicon.ico
Normal file
BIN
apps/web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 901 B |
179
apps/web/app/globals.css
Normal file
179
apps/web/app/globals.css
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-bg: #0b0f17;
|
||||||
|
--color-surface: #111827;
|
||||||
|
--color-surface-muted: #0f172a;
|
||||||
|
--color-input: #0b1220;
|
||||||
|
--color-border: rgba(148, 163, 184, 0.18);
|
||||||
|
--color-divider: rgba(30, 144, 255, 0.25);
|
||||||
|
--color-accent: #1e90ff;
|
||||||
|
--color-accent-strong: #4aa3ff;
|
||||||
|
--color-accent-soft: rgba(30, 144, 255, 0.12);
|
||||||
|
--color-accent-border: rgba(30, 144, 255, 0.55);
|
||||||
|
--color-accent-border-weak: rgba(30, 144, 255, 0.32);
|
||||||
|
--color-accent-focus: rgba(30, 144, 255, 0.45);
|
||||||
|
--color-text: #e5e7eb;
|
||||||
|
--color-muted: #a1acc1;
|
||||||
|
--color-muted-2: #7b8aa3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-bg: #f8fafc;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #f1f5f9;
|
||||||
|
--color-input: #ffffff;
|
||||||
|
--color-border: rgba(30, 144, 255, 0.25);
|
||||||
|
--color-divider: rgba(30, 144, 255, 0.2);
|
||||||
|
--color-accent: #1e90ff;
|
||||||
|
--color-accent-strong: #0f7be6;
|
||||||
|
--color-accent-soft: rgba(30, 144, 255, 0.12);
|
||||||
|
--color-accent-border: rgba(30, 144, 255, 0.55);
|
||||||
|
--color-accent-border-weak: rgba(30, 144, 255, 0.32);
|
||||||
|
--color-accent-focus: rgba(30, 144, 255, 0.35);
|
||||||
|
--color-text: #0f172a;
|
||||||
|
--color-muted: #64748b;
|
||||||
|
--color-muted-2: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.bg-app {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-surface {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-panel {
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent-soft {
|
||||||
|
background-color: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-input {
|
||||||
|
background-color: var(--color-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-soft {
|
||||||
|
color: var(--color-muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-default {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent {
|
||||||
|
border-color: var(--color-accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent-weak {
|
||||||
|
border-color: var(--color-accent-border-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent-strong {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
background-color: var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-accent {
|
||||||
|
border-color: var(--color-accent-border-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-accent-border-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-base {
|
||||||
|
background-color: var(--color-input);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-base:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-accent-focus);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
background-color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-accent {
|
||||||
|
border: 1px solid var(--color-accent-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-accent:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the calendar icon on Chromium/Safari */
|
||||||
|
input.no-date-icon::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
/* usually fine */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: hide the little spin buttons some browsers show */
|
||||||
|
input.no-date-icon::-webkit-inner-spin-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/web/app/groups/[id]/settings/page.tsx
Normal file
14
apps/web/app/groups/[id]/settings/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import GroupSettingsContent from "@/components/group-settings-content";
|
||||||
|
|
||||||
|
export default async function GroupSettingsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const groupId = Number(id || 0);
|
||||||
|
if (!groupId) redirect("/");
|
||||||
|
|
||||||
|
return <GroupSettingsContent groupId={groupId} />;
|
||||||
|
}
|
||||||
16
apps/web/app/groups/settings/page.tsx
Normal file
16
apps/web/app/groups/settings/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
|
import GroupSettingsContent from "@/components/group-settings-content";
|
||||||
|
|
||||||
|
export default async function GroupSettingsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupId = await requireActiveGroup(user.id);
|
||||||
|
return <GroupSettingsContent groupId={groupId} />;
|
||||||
|
} catch {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
282
apps/web/app/invite/[token]/page.tsx
Normal file
282
apps/web/app/invite/[token]/page.tsx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
|
import useInviteLink from "@/hooks/use-invite-link";
|
||||||
|
|
||||||
|
export default function InvitePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { checkSession } = useAuthContext();
|
||||||
|
const token = useMemo(() => {
|
||||||
|
const raw = params?.token;
|
||||||
|
if (typeof raw === "string") return raw;
|
||||||
|
if (Array.isArray(raw)) return raw[0] || "";
|
||||||
|
return "";
|
||||||
|
}, [params]);
|
||||||
|
const { link, loading, accepting, error, result, accept } = useInviteLink(token || null);
|
||||||
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
const [redirectOpen, setRedirectOpen] = useState(false);
|
||||||
|
const [redirectMessage, setRedirectMessage] = useState("");
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(3);
|
||||||
|
const viewerStatus = link?.viewerStatus || "";
|
||||||
|
const isAlreadyMember = viewerStatus === "ALREADY_MEMBER";
|
||||||
|
const isPendingViewer = viewerStatus === "PENDING";
|
||||||
|
const groupPolicy = link?.groupJoinPolicy || link?.policy || "NOT_ACCEPTING";
|
||||||
|
const isDisabled = groupPolicy === "NOT_ACCEPTING";
|
||||||
|
const isManual = groupPolicy === "APPROVAL_REQUIRED";
|
||||||
|
const isRevoked = Boolean(link?.revokedAt);
|
||||||
|
const isExpired = link?.expiresAt ? new Date(link.expiresAt).getTime() < Date.now() : false;
|
||||||
|
const isUsed = Boolean(link?.singleUse && link?.usedAt);
|
||||||
|
const joinBlockedMessage = isAlreadyMember
|
||||||
|
? "You are already a member of this group."
|
||||||
|
: isPendingViewer
|
||||||
|
? "You currently have a pending join request."
|
||||||
|
: isDisabled
|
||||||
|
? "Invites are disabled for this group."
|
||||||
|
: isRevoked
|
||||||
|
? "This invite link has been revoked."
|
||||||
|
: isExpired
|
||||||
|
? "This invite link has expired."
|
||||||
|
: isUsed
|
||||||
|
? "This invite link has already been used."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function runCheck() {
|
||||||
|
const user = await checkSession();
|
||||||
|
if (!active) return;
|
||||||
|
setHasSession(Boolean(user));
|
||||||
|
setCheckingSession(false);
|
||||||
|
}
|
||||||
|
runCheck();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [checkSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!link || loading) return;
|
||||||
|
if (isAlreadyMember) {
|
||||||
|
setRedirectMessage("You are already a member of this group.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPendingViewer) {
|
||||||
|
setRedirectMessage("Your join request is pending approval.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRevoked) {
|
||||||
|
setRedirectMessage("This invite link has been revoked.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
} else if (isExpired) {
|
||||||
|
setRedirectMessage("This invite link has expired.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
} else if (isUsed) {
|
||||||
|
setRedirectMessage("This invite link has already been used.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
}
|
||||||
|
}, [link, loading, isAlreadyMember, isPendingViewer, isRevoked, isExpired, isUsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return;
|
||||||
|
const lower = error.toLowerCase();
|
||||||
|
if (lower.includes("revoked")) {
|
||||||
|
setRedirectMessage("This invite link has been revoked.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower.includes("expired")) {
|
||||||
|
setRedirectMessage("This invite link has expired.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower.includes("used")) {
|
||||||
|
setRedirectMessage("This invite link has already been used.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower.includes("not found") || lower.includes("invalid invite")) {
|
||||||
|
setRedirectMessage("This invite link no longer exists.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!result) return;
|
||||||
|
if (result.status === "JOINED") setRedirectMessage("You have joined the group.");
|
||||||
|
if (result.status === "PENDING") setRedirectMessage("Your join request is pending approval.");
|
||||||
|
if (result.status === "ALREADY_MEMBER") setRedirectMessage("You are already a member of this group.");
|
||||||
|
setRedirectOpen(true);
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!redirectOpen) return;
|
||||||
|
setSecondsLeft(3);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setSecondsLeft(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
router.push("/");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [redirectOpen, router]);
|
||||||
|
|
||||||
|
const actionLabel = result?.status === "JOINED"
|
||||||
|
? "Joined"
|
||||||
|
: result?.status === "PENDING"
|
||||||
|
? "Request sent"
|
||||||
|
: result?.status === "ALREADY_MEMBER"
|
||||||
|
? "Already a member"
|
||||||
|
: "Join group";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src="/icons/navbar-settings.png"
|
||||||
|
alt=""
|
||||||
|
className="h-7 w-7 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-semibold">Group invite</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel panel-accent p-4 space-y-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="card-title">Invite details</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-muted">Loading invite…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
|
) : link ? (
|
||||||
|
<div className="space-y-2 text-sm text-muted">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-soft">Group</div>
|
||||||
|
<div className="text-base font-semibold text-[color:var(--color-text)]">{link.groupName}</div>
|
||||||
|
</div>
|
||||||
|
{isAlreadyMember ? (
|
||||||
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
|
||||||
|
You are already a member of this group.
|
||||||
|
</div>
|
||||||
|
) : isPendingViewer ? (
|
||||||
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
|
||||||
|
Your join request is pending approval.
|
||||||
|
</div>
|
||||||
|
) : isDisabled ? (
|
||||||
|
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
|
Invites are disabled for this group.
|
||||||
|
</div>
|
||||||
|
) : isRevoked ? (
|
||||||
|
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
|
This invite link has been revoked.
|
||||||
|
</div>
|
||||||
|
) : isExpired ? (
|
||||||
|
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
|
This invite link has expired.
|
||||||
|
</div>
|
||||||
|
) : isUsed ? (
|
||||||
|
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
|
This invite link has already been used.
|
||||||
|
</div>
|
||||||
|
) : isManual ? (
|
||||||
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
|
||||||
|
Requests to join require approval.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">Invite not found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel panel-accent p-4 space-y-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="card-title">Join this group</div>
|
||||||
|
</div>
|
||||||
|
{checkingSession ? (
|
||||||
|
<div className="text-sm text-muted">Checking session…</div>
|
||||||
|
) : !hasSession ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={() => router.push("/login")}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={() => router.push("/register")}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result ? (
|
||||||
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm text-muted">
|
||||||
|
{result.status === "JOINED" && "You joined the group."}
|
||||||
|
{result.status === "ALREADY_MEMBER" && "You are already in this group."}
|
||||||
|
{result.status === "PENDING" && "Join request sent for approval."}
|
||||||
|
</div>
|
||||||
|
) : joinBlockedMessage ? (
|
||||||
|
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{joinBlockedMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!joinBlockedMessage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
disabled={!link || Boolean(result) || accepting}
|
||||||
|
onClick={accept}
|
||||||
|
>
|
||||||
|
{accepting ? "Joining…" : actionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
>
|
||||||
|
Go to dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{redirectOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 !mt-0">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5">
|
||||||
|
<div className="text-lg font-semibold">Redirecting</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">{redirectMessage}</p>
|
||||||
|
<p className="mt-2 text-xs text-soft">Taking you to entries in {secondsLeft}s.</p>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
>
|
||||||
|
Go now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/app/layout.tsx
Normal file
21
apps/web/app/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import AppProviders from "@/components/app-providers";
|
||||||
|
import AppFrame from "@/components/app-frame";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Fiddy",
|
||||||
|
description: "Budgeting & collaboration"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<AppProviders>
|
||||||
|
<AppFrame>{children}</AppFrame>
|
||||||
|
</AppProviders>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/app/login/page.tsx
Normal file
141
apps/web/app/login/page.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [remember, setRemember] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { checkSession, login, loading } = useAuthContext();
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function runCheckSession() {
|
||||||
|
try {
|
||||||
|
const user = await checkSession();
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) setChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCheckSession();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [router, checkSession]);
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (!email.trim()) return "Email is required";
|
||||||
|
if (!email.includes("@")) return "Enter a valid email";
|
||||||
|
if (!password) return "Password is required";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const validationError = validate();
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
const result = await login({ email, password, remember });
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error || "Login failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-sm space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src="/icons/navbar-settings.png"
|
||||||
|
alt=""
|
||||||
|
className="h-7 w-7 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-semibold">Login</h1>
|
||||||
|
</div>
|
||||||
|
{checking ? (
|
||||||
|
<div className="panel panel-accent p-4 text-muted">
|
||||||
|
Checking session...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="space-y-3 panel panel-accent p-4"
|
||||||
|
>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={loading || checking}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={loading || checking}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-accent-weak bg-input"
|
||||||
|
checked={remember}
|
||||||
|
onChange={e => setRemember(e.target.checked)}
|
||||||
|
disabled={loading || checking}
|
||||||
|
/>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={e => e.preventDefault()}
|
||||||
|
className="text-[color:var(--color-accent)]"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || checking}
|
||||||
|
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
<div className="text-center text-sm text-muted">
|
||||||
|
No account?{" "}
|
||||||
|
<a href="/register" className="text-[color:var(--color-accent)]">
|
||||||
|
Register
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/web/app/page.tsx
Normal file
10
apps/web/app/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import DashboardContent from "@/components/dashboard-content";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return <DashboardContent />;
|
||||||
|
}
|
||||||
90
apps/web/app/register/page.tsx
Normal file
90
apps/web/app/register/page.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { register, loading } = useAuthContext();
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
const result = await register({ email, password, displayName });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error || "Registration failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-sm space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src="/icons/navbar-settings.png"
|
||||||
|
alt=""
|
||||||
|
className="h-7 w-7 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-semibold">Register</h1>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="space-y-3 panel panel-accent p-4"
|
||||||
|
>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Display name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={displayName}
|
||||||
|
onChange={e => setDisplayName(e.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? "Creating account..." : "Create account"}
|
||||||
|
</button>
|
||||||
|
<div className="text-center text-sm text-muted">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login" className="text-[color:var(--color-accent)]">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/components/app-frame.tsx
Normal file
26
apps/web/components/app-frame.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Navbar from "@/components/navbar";
|
||||||
|
|
||||||
|
const NO_NAVBAR_PATHS = new Set(["/login", "/register"]);
|
||||||
|
const NO_NAVBAR_PREFIXES = ["/invite"];
|
||||||
|
|
||||||
|
type AppFrameProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppFrame({ children }: AppFrameProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const hideNavbar = pathname
|
||||||
|
? NO_NAVBAR_PATHS.has(pathname) || NO_NAVBAR_PREFIXES.some(prefix => pathname.startsWith(prefix))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hideNavbar ? null : <Navbar />}
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/components/app-providers.tsx
Normal file
20
apps/web/components/app-providers.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { AuthProvider } from "@/hooks/auth-context";
|
||||||
|
import { GroupsProvider } from "@/hooks/groups-context";
|
||||||
|
import { NotificationsProvider } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
|
type AppProvidersProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppProviders({ children }: AppProvidersProps) {
|
||||||
|
return (
|
||||||
|
<NotificationsProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<GroupsProvider>{children}</GroupsProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
apps/web/components/bucket-card.tsx
Normal file
151
apps/web/components/bucket-card.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { Bucket } from "@/lib/server/buckets";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
|
||||||
|
type BucketCardProps = {
|
||||||
|
bucket: Bucket;
|
||||||
|
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
|
isExpanded: boolean;
|
||||||
|
toggleExpanded: (bucketId: number) => void;
|
||||||
|
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
|
||||||
|
setConfirmDeleteId: (bucketId: number) => void;
|
||||||
|
openEdit: (bucketId: number) => void;
|
||||||
|
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
usageLabel: string;
|
||||||
|
renderUsageBar: (bucket: Bucket) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BucketCard({
|
||||||
|
bucket,
|
||||||
|
icon,
|
||||||
|
isExpanded,
|
||||||
|
toggleExpanded,
|
||||||
|
isMenuOpen,
|
||||||
|
setMenuOpenId,
|
||||||
|
setConfirmDeleteId,
|
||||||
|
openEdit,
|
||||||
|
limit,
|
||||||
|
usageLabel,
|
||||||
|
renderUsageBar,
|
||||||
|
}: BucketCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
||||||
|
onClick={() => toggleExpanded(bucket.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||||
|
{icon || "🚫"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{bucket.name}</div>
|
||||||
|
{bucket.description ? (
|
||||||
|
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
||||||
|
{bucket.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative" data-bucket-menu>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
|
||||||
|
}}
|
||||||
|
aria-label="Bucket actions"
|
||||||
|
data-bucket-menu-button
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEdit(bucket.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setConfirmDeleteId(bucket.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{limit > 0 ? (
|
||||||
|
<>
|
||||||
|
{renderUsageBar(bucket)}
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||||
|
<div>{usageLabel}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{bucket.tags?.length ? (
|
||||||
|
bucket.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-soft">No tags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : isExpanded ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
|
||||||
|
{bucket.tags?.length ? (
|
||||||
|
bucket.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-soft">No tags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(BucketCard, (prev, next) => (
|
||||||
|
prev.bucket === next.bucket
|
||||||
|
&& prev.icon === next.icon
|
||||||
|
&& prev.isExpanded === next.isExpanded
|
||||||
|
&& prev.isMenuOpen === next.isMenuOpen
|
||||||
|
&& prev.limit === next.limit
|
||||||
|
&& prev.usageLabel === next.usageLabel
|
||||||
|
));
|
||||||
230
apps/web/components/buckets-panel.tsx
Normal file
230
apps/web/components/buckets-panel.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
import useBuckets from "@/hooks/use-buckets";
|
||||||
|
import useTags from "@/hooks/use-tags";
|
||||||
|
import NewBucketModal from "@/components/new-bucket-modal";
|
||||||
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
|
import BucketCard from "./bucket-card";
|
||||||
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
|
|
||||||
|
export default function BucketsPanel() {
|
||||||
|
const { activeGroupId } = useGroupsContext();
|
||||||
|
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
||||||
|
const { mutationVersion } = useEntryMutation();
|
||||||
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||||
|
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
|
||||||
|
const [expandedIds, setExpandedIds] = useState<number[]>([]);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
iconKey: "none",
|
||||||
|
budgetLimitDollars: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
necessity: "BOTH",
|
||||||
|
windowDays: "30"
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
||||||
|
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (!menuOpenId) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
|
||||||
|
setMenuOpenId(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [menuOpenId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeGroupId) return;
|
||||||
|
if (mutationVersion === 0) return;
|
||||||
|
reload();
|
||||||
|
}, [mutationVersion, activeGroupId, reload]);
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setForm({ name: "", description: "", iconKey: "none", budgetLimitDollars: "", tags: [], necessity: "BOTH", windowDays: "30" });
|
||||||
|
setEditId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(bucketId: number) {
|
||||||
|
const bucket = buckets.find(item => item.id === bucketId);
|
||||||
|
if (!bucket) return;
|
||||||
|
setEditId(bucketId);
|
||||||
|
setForm({
|
||||||
|
name: bucket.name,
|
||||||
|
description: bucket.description || "",
|
||||||
|
iconKey: bucket.iconKey || "none",
|
||||||
|
budgetLimitDollars: bucket.budgetLimitDollars != null ? String(bucket.budgetLimitDollars) : "",
|
||||||
|
tags: bucket.tags || [],
|
||||||
|
necessity: bucket.necessity,
|
||||||
|
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
setMenuOpenId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const budget = form.budgetLimitDollars ? Number(form.budgetLimitDollars) : null;
|
||||||
|
const windowDays = form.windowDays ? Number(form.windowDays) : 30;
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
const iconKey = form.iconKey && form.iconKey !== "none" ? form.iconKey : null;
|
||||||
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) return;
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
const ok = await updateBucket({
|
||||||
|
id: editId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
iconKey,
|
||||||
|
budgetLimitDollars: budget,
|
||||||
|
tags: form.tags,
|
||||||
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
windowDays
|
||||||
|
});
|
||||||
|
if (ok) setModalOpen(false);
|
||||||
|
} else {
|
||||||
|
const ok = await createBucket({
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
iconKey,
|
||||||
|
budgetLimitDollars: budget,
|
||||||
|
tags: form.tags,
|
||||||
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
windowDays
|
||||||
|
});
|
||||||
|
if (ok) setModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(bucketId: number) {
|
||||||
|
setExpandedIds(prev => prev.includes(bucketId)
|
||||||
|
? prev.filter(id => id !== bucketId)
|
||||||
|
: [...prev, bucketId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function budgetUsage(bucket: typeof buckets[number]) {
|
||||||
|
const limit = bucket.budgetLimitDollars || 0;
|
||||||
|
const spent = bucket.totalUsage || 0;
|
||||||
|
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
||||||
|
return { limit, spent, pct };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsageBar(bucket: typeof buckets[number]) {
|
||||||
|
const { limit, spent, pct } = budgetUsage(bucket);
|
||||||
|
if (!limit) return null;
|
||||||
|
const clamped = Math.max(0, pct);
|
||||||
|
const overage = Math.max(0, clamped - 100);
|
||||||
|
const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400";
|
||||||
|
const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400";
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-2 w-full rounded-full bg-surface">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${tone}`}
|
||||||
|
style={{ width: `${Math.min(100, clamped)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2 className="card-title text-lg">Buckets</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreate}
|
||||||
|
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
|
||||||
|
disabled={!activeGroupId}
|
||||||
|
aria-label="Add bucket"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
|
||||||
|
|
||||||
|
{!activeGroupId ? (
|
||||||
|
<div className="text-sm text-muted">Select a group to view buckets.</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1].map(row => (
|
||||||
|
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-28 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-40 rounded bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
) : orderedBuckets.length ? (
|
||||||
|
orderedBuckets.map(bucket => {
|
||||||
|
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
||||||
|
const { limit, spent } = budgetUsage(bucket);
|
||||||
|
const isExpanded = expandedIds.includes(bucket.id);
|
||||||
|
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
||||||
|
return <BucketCard
|
||||||
|
key={bucket.id}
|
||||||
|
bucket={bucket}
|
||||||
|
icon={icon}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
toggleExpanded={toggleExpanded}
|
||||||
|
isMenuOpen={menuOpenId === bucket.id}
|
||||||
|
setMenuOpenId={setMenuOpenId}
|
||||||
|
setConfirmDeleteId={setConfirmDeleteId}
|
||||||
|
openEdit={openEdit}
|
||||||
|
limit={limit}
|
||||||
|
usageLabel={usageLabel}
|
||||||
|
renderUsageBar={renderUsageBar}
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No buckets yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NewBucketModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
title={editId ? "Edit bucket" : "New bucket"}
|
||||||
|
form={form}
|
||||||
|
error={error}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
/>
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={Boolean(confirmDeleteId)}
|
||||||
|
title="Delete bucket"
|
||||||
|
description="This will permanently remove the bucket."
|
||||||
|
confirmLabel="Delete bucket"
|
||||||
|
onClose={() => setConfirmDeleteId(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!confirmDeleteId) return;
|
||||||
|
const ok = await deleteBucket(confirmDeleteId);
|
||||||
|
if (ok) setConfirmDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/components/confirm-slide-modal.tsx
Normal file
105
apps/web/components/confirm-slide-modal.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type ConfirmSlideModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfirmSlideModal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
onClose,
|
||||||
|
onConfirm
|
||||||
|
}: ConfirmSlideModalProps) {
|
||||||
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const handleSize = 44;
|
||||||
|
|
||||||
|
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
if (!dragging) return;
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
||||||
|
setDragX(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
if (!dragging) return;
|
||||||
|
setDragging(false);
|
||||||
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
const threshold = (track.clientWidth - handleSize) * 0.8;
|
||||||
|
if (dragX >= threshold) {
|
||||||
|
setDragX(0);
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5" onClick={event => event.stopPropagation()}>
|
||||||
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
|
{description ? <p className="mt-2 text-sm text-muted">{description}</p> : null}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs text-soft">Slide to confirm</div>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="mt-2 h-11 rounded-full border border-accent-weak bg-surface relative overflow-hidden touch-none select-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
|
||||||
|
style={{ width: dragX + handleSize }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-0 left-0 h-11 w-11 rounded-full border border-accent bg-panel text-xl font-semibold text-[color:var(--color-text)] touch-none select-none leading-none"
|
||||||
|
style={{ transform: `translateX(${dragX}px)` }}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
aria-label="Slide to confirm"
|
||||||
|
>
|
||||||
|
▸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-soft">{confirmLabel}</div>
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/components/dashboard-content.tsx
Normal file
45
apps/web/components/dashboard-content.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
import EntriesPanel from "@/components/entries-panel";
|
||||||
|
import BucketsPanel from "@/components/buckets-panel";
|
||||||
|
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
||||||
|
|
||||||
|
|
||||||
|
export default function DashboardContent() {
|
||||||
|
const { groups, activeGroupId, loading } = useGroupsContext();
|
||||||
|
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
<div className="h-4 w-40 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-64 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-52 rounded bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeGroup) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4 text-sm text-muted">
|
||||||
|
Create or join a group to add entries.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntryMutationProvider>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<BucketsPanel />
|
||||||
|
<EntriesPanel />
|
||||||
|
</div>
|
||||||
|
</EntryMutationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
785
apps/web/components/entries-panel.tsx
Normal file
785
apps/web/components/entries-panel.tsx
Normal file
@ -0,0 +1,785 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import useEntries from "@/hooks/use-entries";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
import NewEntryModal from "@/components/new-entry-modal";
|
||||||
|
import EntryDetailsModal from "@/components/entry-details-modal";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
import useTags from "@/hooks/use-tags";
|
||||||
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
|
import useGroupSettings from "@/hooks/use-group-settings";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
|
||||||
|
|
||||||
|
export default function EntriesPanel() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { groups, activeGroupId } = useGroupsContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
|
const { settings } = useGroupSettings(activeGroupId);
|
||||||
|
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
||||||
|
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || settings.allowMemberTagManage));
|
||||||
|
const emptyTagActionLabel = canManageTags
|
||||||
|
? "No Tags Assigned Yet - Click To Assign Tags"
|
||||||
|
: "No Tags Assigned Yet - Contact Your Group Admin";
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
amountDollars: "",
|
||||||
|
occurredAt: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
entryType: "SPENDING" as "SPENDING" | "INCOME",
|
||||||
|
isRecurring: false,
|
||||||
|
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||||
|
endCount: "",
|
||||||
|
endDate: ""
|
||||||
|
});
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
|
const [detailsForm, setDetailsForm] = useState({
|
||||||
|
amountDollars: "",
|
||||||
|
occurredAt: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
entryType: "SPENDING" as "SPENDING" | "INCOME",
|
||||||
|
isRecurring: false,
|
||||||
|
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||||
|
endCount: "",
|
||||||
|
endDate: ""
|
||||||
|
});
|
||||||
|
const [detailsOriginal, setDetailsOriginal] = useState<typeof detailsForm | null>(null);
|
||||||
|
const [removedTags, setRemovedTags] = useState<string[]>([]);
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [discardOpen, setDiscardOpen] = useState(false);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
|
||||||
|
const emptyFilters = {
|
||||||
|
amountMin: "",
|
||||||
|
amountMax: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
necessity: "ANY",
|
||||||
|
notesQuery: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
tagsMode: "ANY" as "ANY" | "ALL"
|
||||||
|
};
|
||||||
|
const [filters, setFilters] = useState(emptyFilters);
|
||||||
|
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagsInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pendingDiscardRef = useRef<
|
||||||
|
| { type: "close" }
|
||||||
|
| { type: "prev" }
|
||||||
|
| { type: "next" }
|
||||||
|
| { type: "open"; entry: typeof entries[number]; index: number }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
if (!entries.length) return entries;
|
||||||
|
const min = filters.amountMin ? Number(filters.amountMin) : null;
|
||||||
|
const max = filters.amountMax ? Number(filters.amountMax) : null;
|
||||||
|
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
|
||||||
|
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
|
||||||
|
const query = filters.notesQuery.trim().toLowerCase();
|
||||||
|
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
|
||||||
|
return entries.filter(entry => {
|
||||||
|
if (min != null && entry.amountDollars < min) return false;
|
||||||
|
if (max != null && entry.amountDollars > max) return false;
|
||||||
|
if (from != null) {
|
||||||
|
const time = new Date(entry.occurredAt).getTime();
|
||||||
|
if (!Number.isNaN(from) && time < from) return false;
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
const time = new Date(entry.occurredAt).getTime();
|
||||||
|
if (!Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
||||||
|
}
|
||||||
|
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
|
||||||
|
if (query) {
|
||||||
|
const notes = (entry.notes || "").toLowerCase();
|
||||||
|
if (!notes.includes(query)) return false;
|
||||||
|
}
|
||||||
|
if (tagsFilter.length) {
|
||||||
|
const entryTags = (entry.tags || []).map(tag => tag.toLowerCase());
|
||||||
|
if (filters.tagsMode === "ALL") {
|
||||||
|
if (!tagsFilter.every(tag => entryTags.includes(tag))) return false;
|
||||||
|
} else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [entries, filters]);
|
||||||
|
const visibleEntries = useMemo(() => filteredEntries.filter(entry => entry.isRecurring === (entryTab === "RECURRING")), [filteredEntries, entryTab]);
|
||||||
|
const totalEntries = visibleEntries.length;
|
||||||
|
const activeFilterCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (filters.amountMin) count += 1;
|
||||||
|
if (filters.amountMax) count += 1;
|
||||||
|
if (filters.dateFrom) count += 1;
|
||||||
|
if (filters.dateTo) count += 1;
|
||||||
|
if (filters.necessity !== "ANY") count += 1;
|
||||||
|
if (filters.notesQuery.trim()) count += 1;
|
||||||
|
if (filters.tags.length) count += 1;
|
||||||
|
return count;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
function handleEmptyTagAction() {
|
||||||
|
if (!activeGroupId || !canManageTags) return;
|
||||||
|
router.push("/groups/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagsInputRef.current)
|
||||||
|
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
|
||||||
|
}, [form.tags.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterOpen && !discardOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (discardOpen) handleCancelDiscard();
|
||||||
|
if (filterOpen) setFilterOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [discardOpen, filterOpen]);
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tagsInputRef.current)
|
||||||
|
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
|
||||||
|
if (!e.currentTarget.reportValidity()) {
|
||||||
|
if (!form.amountDollars) amountInputRef.current?.focus();
|
||||||
|
else if (!form.tags.length) tagsInputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amountDollars = Number(form.amountDollars || 0);
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.occurredAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.tags.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseType = form.tags.join(", ") || "General";
|
||||||
|
|
||||||
|
const nextRunAt = form.isRecurring ? form.occurredAt : null;
|
||||||
|
const createdEntry = await createEntry({
|
||||||
|
entryType: form.entryType,
|
||||||
|
amountDollars,
|
||||||
|
occurredAt: form.occurredAt,
|
||||||
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
tags: form.tags,
|
||||||
|
isRecurring: form.isRecurring,
|
||||||
|
frequency: form.isRecurring ? form.frequency : null,
|
||||||
|
intervalCount: form.intervalCount,
|
||||||
|
endCondition: form.isRecurring ? form.endCondition : null,
|
||||||
|
endCount: form.endCondition === "AFTER_COUNT" ? Number(form.endCount || 0) || null : null,
|
||||||
|
endDate: form.endCondition === "BY_DATE" ? form.endDate || null : null,
|
||||||
|
nextRunAt
|
||||||
|
});
|
||||||
|
if (createdEntry) {
|
||||||
|
setForm({
|
||||||
|
amountDollars: "",
|
||||||
|
occurredAt: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [],
|
||||||
|
entryType: "SPENDING",
|
||||||
|
isRecurring: false,
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
endCount: "",
|
||||||
|
endDate: ""
|
||||||
|
});
|
||||||
|
setIsModalOpen(false);
|
||||||
|
notify({
|
||||||
|
title: "Entry added",
|
||||||
|
message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`,
|
||||||
|
tone: "success"
|
||||||
|
});
|
||||||
|
emitEntryMutated({ before: null, after: createdEntry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDetailsFromEntry(entry: typeof entries[number], index: number) {
|
||||||
|
const nextId = Number(entry.id);
|
||||||
|
if (!Number.isFinite(nextId) || nextId <= 0) {
|
||||||
|
alert("Invalid entry id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextForm = {
|
||||||
|
amountDollars: String(entry.amountDollars),
|
||||||
|
occurredAt: new Date(entry.occurredAt).toISOString().slice(0, 10),
|
||||||
|
necessity: entry.necessity,
|
||||||
|
notes: entry.notes || "",
|
||||||
|
tags: entry.tags || [],
|
||||||
|
entryType: entry.entryType,
|
||||||
|
isRecurring: entry.isRecurring,
|
||||||
|
frequency: entry.frequency || "MONTHLY",
|
||||||
|
intervalCount: entry.intervalCount || 1,
|
||||||
|
endCondition: entry.endCondition || "NEVER",
|
||||||
|
endCount: entry.endCount ? String(entry.endCount) : "",
|
||||||
|
endDate: entry.endDate || ""
|
||||||
|
};
|
||||||
|
setSelectedId(nextId);
|
||||||
|
setSelectedIndex(index);
|
||||||
|
setRemovedTags([]);
|
||||||
|
setDetailsForm(nextForm);
|
||||||
|
setDetailsOriginal(nextForm);
|
||||||
|
setDetailsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenDetails(entry: typeof entries[number], index: number) {
|
||||||
|
requestDiscard({ type: "open", entry, index });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags: string[]) {
|
||||||
|
return tags.map(tag => tag.toLowerCase()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterAddTag(tag: string) {
|
||||||
|
setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterToggleTag(tag: string) {
|
||||||
|
setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearFilters() {
|
||||||
|
setFilters(emptyFilters);
|
||||||
|
}
|
||||||
|
function hasDetailsChanges() {
|
||||||
|
if (!detailsOriginal) return false;
|
||||||
|
const currentTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
|
||||||
|
const currentTagsKey = normalizeTags(currentTags).join("|");
|
||||||
|
const originalTagsKey = normalizeTags(detailsOriginal.tags).join("|");
|
||||||
|
return (
|
||||||
|
detailsForm.amountDollars !== detailsOriginal.amountDollars ||
|
||||||
|
detailsForm.occurredAt !== detailsOriginal.occurredAt ||
|
||||||
|
detailsForm.necessity !== detailsOriginal.necessity ||
|
||||||
|
detailsForm.notes !== detailsOriginal.notes ||
|
||||||
|
detailsForm.entryType !== detailsOriginal.entryType ||
|
||||||
|
detailsForm.isRecurring !== detailsOriginal.isRecurring ||
|
||||||
|
detailsForm.frequency !== detailsOriginal.frequency ||
|
||||||
|
detailsForm.intervalCount !== detailsOriginal.intervalCount ||
|
||||||
|
detailsForm.endCondition !== detailsOriginal.endCondition ||
|
||||||
|
detailsForm.endCount !== detailsOriginal.endCount ||
|
||||||
|
detailsForm.endDate !== detailsOriginal.endDate ||
|
||||||
|
currentTagsKey !== originalTagsKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDiscard(action: NonNullable<typeof pendingDiscardRef.current>) {
|
||||||
|
if (!detailsOpen || !hasDetailsChanges()) {
|
||||||
|
runDiscardAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDiscardRef.current = action;
|
||||||
|
setDiscardOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDiscardAction(action: NonNullable<typeof pendingDiscardRef.current>) {
|
||||||
|
if (action.type === "close") {
|
||||||
|
setDetailsOpen(false);
|
||||||
|
setDetailsOriginal(null);
|
||||||
|
setRemovedTags([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === "open") {
|
||||||
|
setDetailsFromEntry(action.entry, action.index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!totalEntries) return;
|
||||||
|
const current = selectedIndex ?? 0;
|
||||||
|
if (action.type === "prev") {
|
||||||
|
const nextIndex = current === 0 ? totalEntries - 1 : current - 1;
|
||||||
|
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
|
||||||
|
}
|
||||||
|
if (action.type === "next") {
|
||||||
|
const nextIndex = current === totalEntries - 1 ? 0 : current + 1;
|
||||||
|
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedId) return;
|
||||||
|
if (!hasDetailsChanges()) return;
|
||||||
|
|
||||||
|
const amountDollars = Number(detailsForm.amountDollars || 0);
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
|
||||||
|
alert("Enter a valid amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!detailsForm.occurredAt) {
|
||||||
|
alert("Select a date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
|
||||||
|
if (!nextTags.length) {
|
||||||
|
alert("Add at least one tag");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseType = nextTags.join(", ") || "General";
|
||||||
|
|
||||||
|
const nextRunAt = detailsForm.isRecurring ? detailsForm.occurredAt : null;
|
||||||
|
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
|
||||||
|
const updatedEntry = await updateEntry({
|
||||||
|
id: selectedId,
|
||||||
|
entryType: detailsForm.entryType,
|
||||||
|
amountDollars,
|
||||||
|
occurredAt: detailsForm.occurredAt,
|
||||||
|
necessity: detailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: detailsForm.notes.trim() || undefined,
|
||||||
|
tags: nextTags,
|
||||||
|
isRecurring: detailsForm.isRecurring,
|
||||||
|
frequency: detailsForm.isRecurring ? detailsForm.frequency : null,
|
||||||
|
intervalCount: detailsForm.intervalCount,
|
||||||
|
endCondition: detailsForm.isRecurring ? detailsForm.endCondition : null,
|
||||||
|
endCount: detailsForm.endCondition === "AFTER_COUNT" ? Number(detailsForm.endCount || 0) || null : null,
|
||||||
|
endDate: detailsForm.endCondition === "BY_DATE" ? detailsForm.endDate || null : null,
|
||||||
|
nextRunAt
|
||||||
|
});
|
||||||
|
if (updatedEntry) {
|
||||||
|
setDetailsOpen(false);
|
||||||
|
setDetailsOriginal(null);
|
||||||
|
setRemovedTags([]);
|
||||||
|
notify({
|
||||||
|
title: "Entry updated",
|
||||||
|
message: `${nextTags.join(", ")} · $${amountDollars.toFixed(2)}`
|
||||||
|
});
|
||||||
|
emitEntryMutated({ before: beforeEntry, after: updatedEntry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedId || !Number.isFinite(selectedId)) return;
|
||||||
|
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
|
||||||
|
const deletedEntry = await deleteEntry(selectedId);
|
||||||
|
if (deletedEntry || beforeEntry) {
|
||||||
|
setDetailsOpen(false);
|
||||||
|
setDetailsOriginal(null);
|
||||||
|
setRemovedTags([]);
|
||||||
|
notify({
|
||||||
|
title: "Entry deleted",
|
||||||
|
message: detailsForm.tags.join(", ") || "Entry removed",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleTag(tag: string) {
|
||||||
|
setRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddTag(tag: string) {
|
||||||
|
setDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
||||||
|
setRemovedTags(prev => prev.filter(item => item !== tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
if (!totalEntries) return;
|
||||||
|
requestDiscard({ type: "prev" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (!totalEntries) return;
|
||||||
|
requestDiscard({ type: "next" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseDetails() {
|
||||||
|
requestDiscard({ type: "close" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmDiscard() {
|
||||||
|
const action = pendingDiscardRef.current;
|
||||||
|
pendingDiscardRef.current = null;
|
||||||
|
setDiscardOpen(false);
|
||||||
|
if (action) runDiscardAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelDiscard() {
|
||||||
|
pendingDiscardRef.current = null;
|
||||||
|
setDiscardOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRevertDetails() {
|
||||||
|
if (!detailsOriginal) return;
|
||||||
|
setDetailsForm(detailsOriginal);
|
||||||
|
setRemovedTags([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="card-title text-lg">Entries</h2>
|
||||||
|
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
|
||||||
|
>
|
||||||
|
Existing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full w-20 px-3 py-2 text-xs font-semibold ${entryTab === "RECURRING" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => setEntryTab(prev => prev === "RECURRING" ? "SINGLE" : "RECURRING")}
|
||||||
|
>
|
||||||
|
Scheduled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilterOpen(true)}
|
||||||
|
className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50"
|
||||||
|
disabled={!activeGroupId}
|
||||||
|
>
|
||||||
|
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
|
||||||
|
disabled={!activeGroupId}
|
||||||
|
aria-label="Add entry"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{!activeGroupId ? (
|
||||||
|
<div className="text-sm text-muted">Select a group to view entries.</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map(row => (
|
||||||
|
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-28 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-40 rounded bg-surface" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-5 w-14 rounded-full bg-surface" />
|
||||||
|
<div className="h-5 w-12 rounded-full bg-surface" />
|
||||||
|
<div className="h-5 w-16 rounded-full bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length ? (
|
||||||
|
visibleEntries.length ? (
|
||||||
|
visibleEntries.map((entry, index) => {
|
||||||
|
const tags = entry.tags ?? [];
|
||||||
|
const mobileTagLimit = 2;
|
||||||
|
const mobileTags = tags.slice(0, mobileTagLimit);
|
||||||
|
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||||
|
onClick={() => handleOpenDetails(entry, index)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tags.length ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 md:hidden">
|
||||||
|
{mobileTags.map(tag => (
|
||||||
|
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{extraTagCount ? (
|
||||||
|
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
|
||||||
|
{extraTagCount} more...
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="hidden flex-wrap justify-end gap-2 md:flex">
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 text-sm text-muted">
|
||||||
|
<div>No matching entries.</div>
|
||||||
|
{activeFilterCount ? (
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={handleClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No entries yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NewEntryModal
|
||||||
|
isOpen={isModalOpen && Boolean(activeGroupId)}
|
||||||
|
form={form}
|
||||||
|
error={error}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
amountInputRef={amountInputRef}
|
||||||
|
tagsInputRef={tagsInputRef}
|
||||||
|
/>
|
||||||
|
<EntryDetailsModal
|
||||||
|
isOpen={detailsOpen}
|
||||||
|
form={detailsForm}
|
||||||
|
originalForm={detailsOriginal}
|
||||||
|
isDirty={hasDetailsChanges()}
|
||||||
|
error={error}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onRequestDelete={() => setConfirmDeleteOpen(true)}
|
||||||
|
onRevert={handleRevertDetails}
|
||||||
|
onChange={next => setDetailsForm(prev => ({ ...prev, ...next }))}
|
||||||
|
onAddTag={handleAddTag}
|
||||||
|
onToggleTag={handleToggleTag}
|
||||||
|
removedTags={removedTags}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
loopHintPrev={selectedIndex === 0 && totalEntries > 1 ? "Loop" : ""}
|
||||||
|
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
|
||||||
|
canNavigate={totalEntries > 1}
|
||||||
|
/>
|
||||||
|
{filterOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Escape") setFilterOpen(false);
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Filter Entries</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilterOpen(false)}
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Amount Range
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className="w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={filters.amountMin}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={e => setFilters(prev => ({ ...prev, amountMin: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-soft">-</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className="w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={filters.amountMax}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={e => setFilters(prev => ({ ...prev, amountMax: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Date Range
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={e => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-soft">-</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={filters.dateTo}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={e => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||||
|
{([
|
||||||
|
{ value: "ANY", label: "Any" },
|
||||||
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
|
{ value: "BOTH", label: "Both" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => setFilters(prev => ({ ...prev, necessity: prev.necessity === option.value ? "ANY" : option.value }))}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Notes contains
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={filters.notesQuery}
|
||||||
|
onChange={e => setFilters(prev => ({ ...prev, notesQuery: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<TagInput
|
||||||
|
label="Tags"
|
||||||
|
labelAction={
|
||||||
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ANY" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ANY" ? "ALL" : "ANY" }))}
|
||||||
|
>
|
||||||
|
Any
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ALL" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ALL" ? "ANY" : "ALL" }))}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
tags={filters.tags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
onToggleTag={handleFilterToggleTag}
|
||||||
|
onAddTag={handleFilterAddTag}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={!canManageTags}
|
||||||
|
onEmptySuggestionClick={handleEmptyTagAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-soft">
|
||||||
|
{activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-2 text-xs" onClick={handleClearFilters}>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={() => setFilterOpen(false)}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{discardOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl"
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Escape") handleCancelDiscard();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold">Discard changes?</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">You have unsaved changes. Do you want to discard them?</p>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={handleCancelDiscard}
|
||||||
|
>
|
||||||
|
Keep editing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
||||||
|
onClick={handleConfirmDiscard}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={confirmDeleteOpen}
|
||||||
|
title="Delete entry"
|
||||||
|
description="This will permanently remove the entry and its tags."
|
||||||
|
confirmLabel="Delete entry"
|
||||||
|
onClose={() => setConfirmDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDeleteOpen(false);
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
387
apps/web/components/entry-details-modal.tsx
Normal file
387
apps/web/components/entry-details-modal.tsx
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
|
||||||
|
export type EntryDetailsForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: string;
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
isRecurring: boolean;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryDetailsModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: EntryDetailsForm;
|
||||||
|
originalForm: EntryDetailsForm | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onRequestDelete: () => void;
|
||||||
|
onRevert: () => void;
|
||||||
|
onChange: (next: Partial<EntryDetailsForm>) => void;
|
||||||
|
onAddTag: (tag: string) => void;
|
||||||
|
onToggleTag: (tag: string) => void;
|
||||||
|
removedTags: string[];
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
loopHintPrev: string;
|
||||||
|
loopHintNext: string;
|
||||||
|
canNavigate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntryDetailsModal({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
originalForm,
|
||||||
|
isDirty,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onRequestDelete,
|
||||||
|
onRevert,
|
||||||
|
onChange,
|
||||||
|
onAddTag,
|
||||||
|
onToggleTag,
|
||||||
|
removedTags,
|
||||||
|
tagSuggestions,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
emptyTagActionDisabled = false,
|
||||||
|
onEmptyTagAction,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
loopHintPrev,
|
||||||
|
loopHintNext,
|
||||||
|
canNavigate
|
||||||
|
}: EntryDetailsModalProps) {
|
||||||
|
const baseline = originalForm ?? form;
|
||||||
|
const removedSet = new Set(removedTags.map(tag => tag.toLowerCase()));
|
||||||
|
const currentTags = form.tags.filter(tag => !removedSet.has(tag.toLowerCase()));
|
||||||
|
const normalizeTags = (tags: string[]) => tags.map(tag => tag.toLowerCase()).sort().join("|");
|
||||||
|
const baselineTags = baseline.tags || [];
|
||||||
|
const tagsChanged = normalizeTags(currentTags) !== normalizeTags(baselineTags);
|
||||||
|
const addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
|
||||||
|
const amountChanged = form.amountDollars !== baseline.amountDollars;
|
||||||
|
const dateChanged = form.occurredAt !== baseline.occurredAt;
|
||||||
|
const necessityChanged = form.necessity !== baseline.necessity;
|
||||||
|
const notesChanged = form.notes !== baseline.notes;
|
||||||
|
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const touchDeltaX = useRef(0);
|
||||||
|
|
||||||
|
function handleTouchStart(event: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||||
|
touchDeltaX.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
if (touchStartX.current === null) return;
|
||||||
|
touchDeltaX.current = event.touches[0]?.clientX - touchStartX.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
if (!canNavigate) return;
|
||||||
|
const delta = touchDeltaX.current;
|
||||||
|
touchStartX.current = null;
|
||||||
|
touchDeltaX.current = 0;
|
||||||
|
if (Math.abs(delta) < 60) return;
|
||||||
|
if (delta > 0) onPrev();
|
||||||
|
else onNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
className="flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||||
|
disabled={!canNavigate}
|
||||||
|
aria-label="Previous entry"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
||||||
|
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||||
|
</button>
|
||||||
|
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="ml-auto flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||||
|
disabled={!canNavigate}
|
||||||
|
aria-label="Next entry"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
||||||
|
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target?.tagName === "TEXTAREA") return;
|
||||||
|
event.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}}
|
||||||
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||||
|
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
||||||
|
title="Toggle Recurring Entry"
|
||||||
|
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||||
|
>
|
||||||
|
<span aria-hidden>⟳</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||||
|
>
|
||||||
|
Spending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||||
|
>
|
||||||
|
Income
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Amount ($)
|
||||||
|
<input
|
||||||
|
name="amountDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className={`mt-1 w-full input-base px-3 py-2 text-sm ${amountChanged ? changedInputClass : ""} ${form.amountDollars ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<input
|
||||||
|
name="occurredAt"
|
||||||
|
type="date"
|
||||||
|
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.occurredAt}
|
||||||
|
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<div className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
|
||||||
|
{([
|
||||||
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
|
{ value: "BOTH", label: "Both" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TagInput
|
||||||
|
label="Tags"
|
||||||
|
tags={form.tags}
|
||||||
|
removedTags={removedTags}
|
||||||
|
highlightTags={addedTags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
chipsBelow
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
onAddTag={onAddTag}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={emptyTagActionDisabled}
|
||||||
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
|
invalid={!currentTags.length}
|
||||||
|
/>
|
||||||
|
{form.isRecurring ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-20 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">day(s)</option>
|
||||||
|
<option value="WEEKLY">week(s)</option>
|
||||||
|
<option value="BIWEEKLY">biweekly</option>
|
||||||
|
<option value="MONTHLY">month(s)</option>
|
||||||
|
<option value="QUARTERLY">quarter(s)</option>
|
||||||
|
<option value="YEARLY">year(s)</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||||
|
{([
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ endCondition: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>‹</button>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>›</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
className={`mt-1 w-full input-base px-3 py-2 text-sm ${notesChanged ? changedInputClass : ""}`}
|
||||||
|
rows={3}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => onChange({ notes: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRevert}
|
||||||
|
disabled={!isDirty}
|
||||||
|
aria-label="Revert changes"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||||
|
<path d="M3 4v4h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
|
||||||
|
disabled={!isDirty}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
||||||
|
onClick={onRequestDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
apps/web/components/group-dropdown.tsx
Normal file
300
apps/web/components/group-dropdown.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
|
type GroupDropdownProps = {
|
||||||
|
onInviteCode: (code: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GroupDropdown({ onInviteCode }: GroupDropdownProps) {
|
||||||
|
const { groups, activeGroupId, loading, error, createGroup, joinGroup, setActiveGroup } = useGroupsContext();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"create" | "join">("create");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
|
const [localError, setLocalError] = useState("");
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
||||||
|
const groupLabel = loading ? "Loading" : activeGroup ? activeGroup.name : "Select group";
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setLocalError("Group name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError("");
|
||||||
|
const group = await createGroup({ name });
|
||||||
|
if (group?.inviteCode) onInviteCode(group.inviteCode);
|
||||||
|
if (group) {
|
||||||
|
setName("");
|
||||||
|
notify({ title: "Group created", message: group.name, tone: "success" });
|
||||||
|
setManageOpen(false);
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/groups/settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
if (!inviteCode.trim()) {
|
||||||
|
setLocalError("Invite code is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalError("");
|
||||||
|
const raw = inviteCode.trim();
|
||||||
|
const inviteTokenMatch = raw.match(/\/invite\/([a-zA-Z0-9]+)/);
|
||||||
|
if (inviteTokenMatch?.[1]) {
|
||||||
|
setInviteCode("");
|
||||||
|
setManageOpen(false);
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`/invite/${inviteTokenMatch[1]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = await joinGroup({ inviteCode: raw });
|
||||||
|
if (group) {
|
||||||
|
setInviteCode("");
|
||||||
|
notify({ title: "Joined group", message: group.name, tone: "success" });
|
||||||
|
setManageOpen(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelect(groupId: number) {
|
||||||
|
const ok = await setActiveGroup(groupId);
|
||||||
|
if (ok) {
|
||||||
|
setOpen(false);
|
||||||
|
const group = groups.find(item => item.id === groupId);
|
||||||
|
if (group) notify({ title: "Active group", message: group.name });
|
||||||
|
if (pathname.startsWith("/groups/")) router.push("/groups/settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickEntries() {
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleIcon(role: string) {
|
||||||
|
if (role === "GROUP_OWNER") return "👑";
|
||||||
|
if (role === "GROUP_ADMIN") return "🛡️";
|
||||||
|
return "👤";
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (!open || !dropdownRef.current) return;
|
||||||
|
if (!dropdownRef.current.contains(event.target as Node))
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manageOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setManageOpen(false);
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
if (activeTab === "create") handleCreate();
|
||||||
|
else handleJoin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [manageOpen, activeTab, handleCreate, handleJoin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Back to entries"
|
||||||
|
onClick={handleQuickEntries}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-l-lg border border-accent-weak bg-panel text-sm hover:border-accent"
|
||||||
|
disabled={loading || !activeGroupId}
|
||||||
|
>
|
||||||
|
$
|
||||||
|
{/* <svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 6l-6 6 6 6" />
|
||||||
|
</svg> */}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className={`-ml-px flex h-9 w-44 items-center justify-between rounded-none border border-accent-weak bg-panel px-3 text-sm hover:border-accent sm:w-56 ${loading ? "animate-pulse" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{groupLabel}</span>
|
||||||
|
<span className="text-l font-bold text-soft">▼</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Group settings"
|
||||||
|
onClick={() => {
|
||||||
|
if (!activeGroupId) return;
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/groups/settings");
|
||||||
|
}}
|
||||||
|
disabled={loading || !activeGroupId}
|
||||||
|
className="-ml-px flex h-9 w-9 items-center justify-center rounded-r-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="text-sm">⚙</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute left-1/2 mt-2 w-64 -translate-x-1/2 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map(row => (
|
||||||
|
<div key={row} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-2 py-2">
|
||||||
|
<div className="h-3 w-32 rounded bg-surface animate-pulse" />
|
||||||
|
<div className="h-3 w-10 rounded bg-surface animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : groups.length ? (
|
||||||
|
groups.map(group => (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left outline-none ${activeGroupId === group.id ? "bg-accent-soft" : "hover:bg-surface"}`}
|
||||||
|
onClick={() => handleSelect(group.id)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") handleSelect(group.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{group.name}</span>
|
||||||
|
<span className="text-xs text-soft" aria-label={group.role}>
|
||||||
|
{roleIcon(group.role)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-soft">No groups yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="my-3 h-px divider" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-accent px-2 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
onClick={() => {
|
||||||
|
setManageOpen(true);
|
||||||
|
setActiveTab("create");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Create or join group
|
||||||
|
</button>
|
||||||
|
{localError || error ? (
|
||||||
|
<div className="text-xs text-red-400">{localError || error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{manageOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[60] flex min-h-[100dvh] items-center justify-center bg-black/60 p-4" onClick={() => setManageOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Escape") setManageOpen(false);
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
if (activeTab === "create") handleCreate();
|
||||||
|
else handleJoin();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold">Manage groups</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
onClick={() => setManageOpen(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
{([
|
||||||
|
{ key: "create", label: "Create" },
|
||||||
|
{ key: "join", label: "Join" }
|
||||||
|
] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-semibold ${activeTab === tab.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeTab === "create" ? (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Group name"
|
||||||
|
className={`w-full input-base px-3 py-2 text-sm ${name.trim() ? "" : "border-red-400/70"}`}
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Create group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Invite link or code"
|
||||||
|
className={`w-full input-base px-3 py-2 text-sm ${inviteCode.trim() ? "" : "border-red-400/70"}`}
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={e => setInviteCode(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Join group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{localError || error ? (
|
||||||
|
<div className="mt-3 text-xs text-red-400">{localError || error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1046
apps/web/components/group-settings-content.tsx
Normal file
1046
apps/web/components/group-settings-content.tsx
Normal file
File diff suppressed because it is too large
Load Diff
186
apps/web/components/navbar.tsx
Normal file
186
apps/web/components/navbar.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import GroupDropdown from "@/components/group-dropdown";
|
||||||
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { checkSession, logout } = useAuthContext();
|
||||||
|
const { activeGroupId } = useGroupsContext();
|
||||||
|
const [inviteModalCode, setInviteModalCode] = useState<string | null>(null);
|
||||||
|
const [inviteCopied, setInviteCopied] = useState(false);
|
||||||
|
const [hideNavbar, setHideNavbar] = useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
async function handleCopyInvite() {
|
||||||
|
if (!inviteModalCode) return;
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(inviteModalCode);
|
||||||
|
setInviteCopied(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function loadUser() {
|
||||||
|
const user = await checkSession();
|
||||||
|
if (active) setUserEmail(user?.email || null);
|
||||||
|
}
|
||||||
|
loadUser();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [checkSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (!userMenuOpen || !userMenuRef.current) return;
|
||||||
|
if (!userMenuRef.current.contains(event.target as Node))
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inviteModalCode) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setInviteModalCode(null);
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [inviteModalCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lastY = window.scrollY;
|
||||||
|
function onScroll() {
|
||||||
|
const current = window.scrollY;
|
||||||
|
const diff = current - lastY;
|
||||||
|
if (Math.abs(diff) < 6) return;
|
||||||
|
if (current <= 8) {
|
||||||
|
setHideNavbar(false);
|
||||||
|
} else if (diff > 0) {
|
||||||
|
setHideNavbar(true);
|
||||||
|
} else {
|
||||||
|
setHideNavbar(false);
|
||||||
|
}
|
||||||
|
lastY = current;
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={`sticky top-0 z-40 border-b border-accent-weak bg-app backdrop-blur transition-transform duration-200 ${hideNavbar ? "-translate-y-full" : "translate-y-0"}`}>
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl border border-accent bg-panel hover:border-accent-strong"
|
||||||
|
aria-label="Settings"
|
||||||
|
onClick={() => {
|
||||||
|
if (activeGroupId) router.push("/groups/settings");
|
||||||
|
else router.push("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="/icons/navbar-settings.png" alt="" className="h-full w-full rounded-lg object-cover" />
|
||||||
|
</button>
|
||||||
|
<div className="hidden sm:block text-sm font-semibold">Fiddy</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupDropdown
|
||||||
|
onInviteCode={code => {
|
||||||
|
setInviteModalCode(code);
|
||||||
|
setInviteCopied(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative" ref={userMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="User menu"
|
||||||
|
onClick={() => setUserMenuOpen(prev => !prev)}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-accent-weak bg-panel text-sm text-muted hover:border-accent"
|
||||||
|
>
|
||||||
|
<span className="text-base">👤</span>
|
||||||
|
</button>
|
||||||
|
{userMenuOpen ? (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
|
||||||
|
<div className="text-xs text-soft">Signed in as</div>
|
||||||
|
<div className="mt-1 truncate text-sm font-semibold text-[color:var(--color-text)]">
|
||||||
|
{userEmail || "User"}
|
||||||
|
</div>
|
||||||
|
<div className="my-3 h-px divider" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
if (activeGroupId) router.push("/groups/settings");
|
||||||
|
else router.push("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 w-full rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-200"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{inviteModalCode ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-6 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl max-h-[90vh]"
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Escape") setInviteModalCode(null);
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold">Invite code</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
Share this code to invite members. You can view it later in group settings.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-lg border border-accent-weak bg-surface px-3 py-2 text-center text-lg tracking-widest">
|
||||||
|
{inviteModalCode}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={handleCopyInvite}
|
||||||
|
>
|
||||||
|
{inviteCopied ? "Copied" : "Copy code"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||||
|
onClick={() => setInviteModalCode(null)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
apps/web/components/new-bucket-modal.tsx
Normal file
222
apps/web/components/new-bucket-modal.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
|
|
||||||
|
type BucketForm = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
iconKey: string;
|
||||||
|
budgetLimitDollars: string;
|
||||||
|
tags: string[];
|
||||||
|
necessity: string;
|
||||||
|
windowDays: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewBucketModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
form: BucketForm;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onChange: (next: Partial<BucketForm>) => void;
|
||||||
|
tagSuggestions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
|
||||||
|
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||||
|
const [iconSearch, setIconSearch] = useState("");
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const normalizedSearch = iconSearch.trim().toLowerCase();
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!normalizedSearch) return bucketIcons;
|
||||||
|
return bucketIcons.filter(item => item.label.toLowerCase().includes(normalizedSearch) || item.key.toLowerCase().includes(normalizedSearch));
|
||||||
|
}, [normalizedSearch]);
|
||||||
|
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
||||||
|
const selectedIcon = form.iconKey ? iconMap.get(form.iconKey) : iconMap.get("none");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (iconModalOpen) setIconModalOpen(false);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [iconModalOpen, isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target?.tagName === "TEXTAREA") return;
|
||||||
|
event.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}}
|
||||||
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-12 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg"
|
||||||
|
onClick={() => setIconModalOpen(true)}
|
||||||
|
>
|
||||||
|
{selectedIcon || "🚫"}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
className={`w-full input-base px-3 py-2 text-sm ${form.name.trim() ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => onChange({ name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Budget limit ($)
|
||||||
|
<input
|
||||||
|
name="budgetLimitDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={form.budgetLimitDollars}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={e => onChange({ budgetLimitDollars: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
Window days
|
||||||
|
<input
|
||||||
|
name="windowDays"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
value={form.windowDays}
|
||||||
|
onChange={e => onChange({ windowDays: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted md:col-span-2">
|
||||||
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||||
|
{([
|
||||||
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
|
{ value: "BOTH", label: "Both" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
rows={2}
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => onChange({ description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<TagInput
|
||||||
|
label="Bucket tags"
|
||||||
|
tags={form.tags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Save bucket
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{iconModalOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4" onClick={() => setIconModalOpen(false)}>
|
||||||
|
<div className="w-full max-w-lg rounded-2xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold">Pick an icon</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
onClick={() => setIconModalOpen(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mt-3 w-full input-base px-3 py-2 text-sm"
|
||||||
|
placeholder="Search icons"
|
||||||
|
value={iconSearch}
|
||||||
|
onChange={e => setIconSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 max-h-[50vh] overflow-auto rounded-lg border border-accent-weak bg-surface p-2">
|
||||||
|
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
|
||||||
|
{filteredIcons.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg border text-lg ${form.iconKey === item.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ iconKey: item.key });
|
||||||
|
setIconModalOpen(false);
|
||||||
|
}}
|
||||||
|
aria-label={item.label}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!filteredIcons.length ? (
|
||||||
|
<div className="py-6 text-center text-sm text-soft">No matching icons.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
apps/web/components/new-entry-modal.tsx
Normal file
297
apps/web/components/new-entry-modal.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
|
||||||
|
type NewEntryForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: string;
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
isRecurring: boolean;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewEntryModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: NewEntryForm;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onChange: (next: Partial<NewEntryForm>) => void;
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
amountInputRef?: React.Ref<HTMLInputElement>;
|
||||||
|
tagsInputRef?: React.Ref<HTMLInputElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
||||||
|
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
|
||||||
|
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.occurredAt) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
onChange({ occurredAt: today, endDate: form.endDate || today });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function shiftDate(days: number) {
|
||||||
|
const base = form.occurredAt ? new Date(form.occurredAt) : new Date();
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + days);
|
||||||
|
onChange({ occurredAt: base.toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||||
|
>
|
||||||
|
Spending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||||
|
>
|
||||||
|
Income
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||||
|
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
||||||
|
title="Toggle Recurring Entry"
|
||||||
|
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||||
|
>
|
||||||
|
<span aria-hidden>⟳</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target?.tagName === "TEXTAREA") return;
|
||||||
|
event.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}}
|
||||||
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
{/* Amount ($) */}
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
||||||
|
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={amountInputRef}
|
||||||
|
name="amountDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className={`w-full input-base px-12 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}>‹</button>
|
||||||
|
<input
|
||||||
|
name="occurredAt"
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
value={form.occurredAt}
|
||||||
|
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}>›</button>
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||||
|
{([
|
||||||
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
|
{ value: "BOTH", label: "Both" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TAGS */}
|
||||||
|
<TagInput
|
||||||
|
label="Tags"
|
||||||
|
tags={form.tags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={emptyTagActionDisabled}
|
||||||
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
|
invalid={!form.tags.length}
|
||||||
|
inputRef={tagsInputRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* RECURRING OPTIONS */}
|
||||||
|
{form.isRecurring ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-14 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">day(s)</option>
|
||||||
|
<option value="WEEKLY">week(s)</option>
|
||||||
|
<option value="MONTHLY">month(s)</option>
|
||||||
|
<option value="YEARLY">year(s)</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||||
|
{([
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-3 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ endCondition: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>‹</button>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>›</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||||
|
rows={3}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => onChange({ notes: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Add entry
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/components/notifications-toaster.tsx
Normal file
55
apps/web/components/notifications-toaster.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type NotificationTone = "info" | "success" | "danger";
|
||||||
|
|
||||||
|
export type NotificationItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
tone: NotificationTone;
|
||||||
|
closing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationsToasterProps = {
|
||||||
|
items: NotificationItem[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toneClasses(tone: NotificationTone) {
|
||||||
|
if (tone === "success") return "border-emerald-400/40 text-emerald-200";
|
||||||
|
if (tone === "danger") return "border-red-400/50 text-red-200";
|
||||||
|
return "border-accent-weak text-[color:var(--color-text)]";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationsToaster({ items, onDismiss }: NotificationsToasterProps) {
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[60] flex w-80 flex-col-reverse gap-2 pointer-events-none">
|
||||||
|
{items.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`pointer-events-auto rounded-xl border bg-panel px-3 py-2 text-sm shadow-lg transition-all duration-300 ${toneClasses(item.tone)} ${item.closing ? "opacity-0 translate-y-2" : "opacity-100"}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{item.title}</div>
|
||||||
|
{item.message ? <div className="text-xs text-soft">{item.message}</div> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-accent-weak px-1.5 py-0.5 text-xs text-soft hover:border-accent"
|
||||||
|
onClick={() => onDismiss(item.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/web/components/recurring-entries-panel.tsx
Normal file
73
apps/web/components/recurring-entries-panel.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import useEntries from "@/hooks/use-entries";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
|
||||||
|
function monthlyMultiplier(frequency: string, intervalCount: number) {
|
||||||
|
const count = intervalCount || 1;
|
||||||
|
switch (frequency) {
|
||||||
|
case "DAILY":
|
||||||
|
return (30 / count);
|
||||||
|
case "WEEKLY":
|
||||||
|
return (52 / 12) / count;
|
||||||
|
case "BIWEEKLY":
|
||||||
|
return (26 / 12) / count;
|
||||||
|
case "MONTHLY":
|
||||||
|
return (1 / count);
|
||||||
|
case "QUARTERLY":
|
||||||
|
return (1 / 3) / count;
|
||||||
|
case "YEARLY":
|
||||||
|
return (1 / 12) / count;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurringEntriesPanel() {
|
||||||
|
const { activeGroupId } = useGroupsContext();
|
||||||
|
const { entries, loading } = useEntries(activeGroupId);
|
||||||
|
|
||||||
|
const recurring = useMemo(() => entries.filter(entry => entry.isRecurring), [entries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2 className="card-title text-lg">Recurring entries</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{!activeGroupId ? (
|
||||||
|
<div className="text-sm text-muted">Select a group to view recurring entries.</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1].map(row => (
|
||||||
|
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-28 rounded bg-surface" />
|
||||||
|
<div className="h-3 w-40 rounded bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recurring.length ? (
|
||||||
|
recurring.map(entry => {
|
||||||
|
const monthly = entry.frequency ? monthlyMultiplier(entry.frequency, entry.intervalCount) * entry.amountDollars : 0;
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">${entry.amountDollars.toFixed(2)} · {entry.tags.join(", ") || "No tags"}</div>
|
||||||
|
<div className="text-xs text-soft">{entry.entryType}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-soft">
|
||||||
|
Next run: {entry.nextRunAt || entry.occurredAt} · Monthly est: ${monthly.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No recurring entries yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/web/components/tag-input.tsx
Normal file
260
apps/web/components/tag-input.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function normalizeTag(tag: string) {
|
||||||
|
return tag
|
||||||
|
.trim()
|
||||||
|
.replace(/^#/, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagInputProps = {
|
||||||
|
label: string;
|
||||||
|
labelAction?: React.ReactNode;
|
||||||
|
tags: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
removedTags?: string[];
|
||||||
|
highlightTags?: string[];
|
||||||
|
onToggleTag?: (tag: string) => void;
|
||||||
|
onAddTag: (tag: string) => void;
|
||||||
|
allowCustom?: boolean;
|
||||||
|
chipsBelow?: boolean;
|
||||||
|
emptySuggestionLabel?: string;
|
||||||
|
emptySuggestionDisabled?: boolean;
|
||||||
|
onEmptySuggestionClick?: () => void;
|
||||||
|
enableBackspaceRemove?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
|
inputRef?: React.Ref<HTMLInputElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TagInput({ label, labelAction, tags, suggestions, removedTags = [], highlightTags = [], onToggleTag, onAddTag, allowCustom = true, chipsBelow = false, emptySuggestionLabel, emptySuggestionDisabled = false, onEmptySuggestionClick, enableBackspaceRemove = false, invalid = false, inputRef }: TagInputProps) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [desiredIndex, setDesiredIndex] = useState(0);
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||||
|
const internalInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const ignoreBlurRef = useRef(false);
|
||||||
|
const removedSet = useMemo(() => new Set(removedTags.map(tag => tag.toLowerCase())), [removedTags]);
|
||||||
|
const highlightSet = useMemo(() => new Set(highlightTags.map(tag => tag.toLowerCase())), [highlightTags]);
|
||||||
|
const normalizedSuggestions = useMemo(() => suggestions
|
||||||
|
.map(tag => ({
|
||||||
|
raw: String(tag),
|
||||||
|
normalized: normalizeTag(String(tag))
|
||||||
|
}))
|
||||||
|
.filter(item => item.normalized.length > 0), [suggestions]);
|
||||||
|
const suggestionSet = useMemo(() => new Set(normalizedSuggestions.map(item => item.normalized.toLowerCase())), [normalizedSuggestions]);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const raw = value.split(/[\n,]/).pop() ?? "";
|
||||||
|
const normalized = normalizeTag(raw);
|
||||||
|
if (!normalized) return [] as string[];
|
||||||
|
return normalizedSuggestions
|
||||||
|
.filter(item => item.normalized.toLowerCase().includes(normalized))
|
||||||
|
.filter(item => !tags.some(existing => normalizeTag(existing) === item.normalized))
|
||||||
|
.map(item => item.raw)
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [normalizedSuggestions, tags, value]);
|
||||||
|
const showEmptySuggestion = !allowCustom && suggestions.length === 0 && Boolean(emptySuggestionLabel) && !filtered.length && value.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtered.length) {
|
||||||
|
setHighlightIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = Math.min(desiredIndex, filtered.length - 1);
|
||||||
|
setHighlightIndex(next);
|
||||||
|
}, [filtered.length, desiredIndex]);
|
||||||
|
|
||||||
|
function setInputRef(node: HTMLInputElement | null) {
|
||||||
|
internalInputRef.current = node;
|
||||||
|
if (!inputRef) return;
|
||||||
|
if (typeof inputRef === "function") {
|
||||||
|
inputRef(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(inputRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTags(raw: string) {
|
||||||
|
const parts = raw.split(/[\n,]+/);
|
||||||
|
const nextTags: string[] = [];
|
||||||
|
const seen = new Set(tags.map(tag => tag.toLowerCase()));
|
||||||
|
for (const part of parts) {
|
||||||
|
const next = normalizeTag(part);
|
||||||
|
if (!next) continue;
|
||||||
|
const lower = next.toLowerCase();
|
||||||
|
if (seen.has(lower)) continue;
|
||||||
|
if (!allowCustom && !suggestionSet.has(lower)) continue;
|
||||||
|
seen.add(lower);
|
||||||
|
nextTags.push(next);
|
||||||
|
}
|
||||||
|
if (!nextTags.length) return false;
|
||||||
|
nextTags.forEach(tag => onAddTag(tag));
|
||||||
|
setValue("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSuggestedTag(raw: string) {
|
||||||
|
const next = normalizeTag(raw);
|
||||||
|
if (!next) return false;
|
||||||
|
if (tags.some(tag => normalizeTag(tag) === next)) return false;
|
||||||
|
onAddTag(next);
|
||||||
|
setValue("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (false && event.key === "Backspace" && !value && tags.length) {
|
||||||
|
console.log("Backspace pressed with empty input, removing last tag");
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleTag?.(tags[tags.length - 1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown" && filtered.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = Math.min(desiredIndex + 1, filtered.length - 1);
|
||||||
|
setDesiredIndex(next);
|
||||||
|
setHighlightIndex(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp" && filtered.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = Math.max(desiredIndex - 1, 0);
|
||||||
|
setDesiredIndex(next);
|
||||||
|
setHighlightIndex(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === "," || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (filtered.length) {
|
||||||
|
const targetIndex = highlightIndex >= 0 ? highlightIndex : 0;
|
||||||
|
addSuggestedTag(filtered[targetIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitTags(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
|
||||||
|
const text = event.clipboardData.getData("text");
|
||||||
|
if (!text) return;
|
||||||
|
if (!/[\n,]/.test(text)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
commitTags(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (ignoreBlurRef.current) {
|
||||||
|
ignoreBlurRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitTags(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="text-sm text-muted">
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
{/* <span>{label}</span> */}
|
||||||
|
{labelAction ? <span className="flex items-center gap-2">{labelAction}</span> : null}
|
||||||
|
</span>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<div className={`rounded-lg border ${invalid ? "border-red-400/70" : "border-accent-weak"} bg-panel p-2`}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{false && !chipsBelow ? tags.map(tag => {
|
||||||
|
const isRemoved = removedSet.has(tag.toLowerCase());
|
||||||
|
const isHighlighted = highlightSet.has(tag.toLowerCase());
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleTag?.(tag)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-xs transition ${isRemoved ? "border-red-400/60 text-red-200 bg-red-500/10" : isHighlighted ? "border-2 border-[color:var(--color-accent)] text-[color:var(--color-text)] bg-accent-soft" : "border-accent-weak text-[color:var(--color-text)] bg-accent-soft hover:border-accent"}`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}) : null}
|
||||||
|
<input
|
||||||
|
ref={setInputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={"Add tags... (who, where, why, etc.)"}
|
||||||
|
className="min-w-[140px] flex-1 bg-transparent text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{true || chipsBelow ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{tags.map(tag => {
|
||||||
|
const isRemoved = removedSet.has(tag.toLowerCase());
|
||||||
|
const isHighlighted = highlightSet.has(tag.toLowerCase());
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleTag?.(tag)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-xs transition ${isRemoved ? "border-red-400/60 text-red-200 bg-red-500/10" : isHighlighted ? "border-2 border-[color:var(--color-accent)] text-[color:var(--color-text)] bg-accent-soft" : "border-accent-weak text-[color:var(--color-text)] bg-accent-soft hover:border-accent"}`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{filtered.length ? (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border border-accent-weak bg-panel shadow-lg">
|
||||||
|
{filtered.map((tag, index) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center justify-between px-2 py-1 text-left text-xs text-muted ${index === highlightIndex ? "bg-accent-soft" : "hover:bg-accent-soft"}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setDesiredIndex(index);
|
||||||
|
setHighlightIndex(index);
|
||||||
|
}}
|
||||||
|
onMouseDown={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
ignoreBlurRef.current = true;
|
||||||
|
addSuggestedTag(tag);
|
||||||
|
internalInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onClick={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>#{tag}</span>
|
||||||
|
<span className="text-[10px] text-soft">Use tag</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : showEmptySuggestion ? (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border-2 border-yellow-400 bg-panel shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-2 py-2 text-left text-sm text-yellow-200 hover:bg-accent-soft disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onMouseDown={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
ignoreBlurRef.current = true;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onEmptySuggestionClick?.();
|
||||||
|
internalInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
disabled={emptySuggestionDisabled}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
{emptySuggestionLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-yellow-200/80">Go</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/e2e/auth.spec.ts
Normal file
21
apps/web/e2e/auth.spec.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { login } from "./test-helpers";
|
||||||
|
|
||||||
|
test("login and register hide navbar", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.locator("header")).toHaveCount(0);
|
||||||
|
await page.goto("/register");
|
||||||
|
await expect(page.locator("header")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login shows entries for seeded owner", async ({ page }) => {
|
||||||
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-group user sees empty state", async ({ page }) => {
|
||||||
|
await login(page, "nogroup@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
await expect(page.getByText("Create or join a group to add entries.")).toBeVisible();
|
||||||
|
});
|
||||||
28
apps/web/e2e/groups.spec.ts
Normal file
28
apps/web/e2e/groups.spec.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { login } from "./test-helpers";
|
||||||
|
|
||||||
|
test("group dropdown lists seeded groups", async ({ page }) => {
|
||||||
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
|
await dropdown.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Beta Office GROUP_OWNER" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Gamma Club MEMBER" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("group settings show join requests and policy", async ({ page }) => {
|
||||||
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Group settings" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||||
|
|
||||||
|
await expect(page.getByText("Join requests")).toBeVisible();
|
||||||
|
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
||||||
|
|
||||||
|
const approvalButton = page.getByRole("button", { name: "Manual" });
|
||||||
|
await expect(approvalButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
6
apps/web/e2e/smoke.spec.ts
Normal file
6
apps/web/e2e/smoke.spec.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("home redirects to login when unauthenticated", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
54
apps/web/e2e/spendings.spec.ts
Normal file
54
apps/web/e2e/spendings.spec.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { login } from "./test-helpers";
|
||||||
|
|
||||||
|
test("seeded entries render with tags and no-tag state", async ({ page }) => {
|
||||||
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||||
|
await expect(page.getByText("$12.50")).toBeVisible();
|
||||||
|
await expect(page.getByText("#Food")).toBeVisible();
|
||||||
|
await expect(page.getByText("#Travel")).toBeVisible();
|
||||||
|
await expect(page.getByText("No tags")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entry details modal opens", async ({ page }) => {
|
||||||
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
await page.getByText("$12.50").click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty tag callout shows contact admin for members", async ({ page }) => {
|
||||||
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
|
await dropdown.click();
|
||||||
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
|
await waitSetActive;
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
|
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||||
|
await expect(callout).toBeVisible();
|
||||||
|
await expect(callout).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
||||||
|
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
|
await dropdown.click();
|
||||||
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
|
await waitSetActive;
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
|
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||||
|
});
|
||||||
13
apps/web/e2e/test-helpers.ts
Normal file
13
apps/web/e2e/test-helpers.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function login(page: Page, email: string, password: string) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill(email);
|
||||||
|
await page.getByLabel("Password").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
const waitGroups = page.waitForResponse(res => res.url().includes("/api/groups") && res.request().method() === "GET");
|
||||||
|
const waitActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "GET");
|
||||||
|
await page.reload();
|
||||||
|
await Promise.all([waitGroups, waitActive]);
|
||||||
|
}
|
||||||
21
apps/web/hooks/auth-context.tsx
Normal file
21
apps/web/hooks/auth-context.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import useAuth from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
|
||||||
|
|
||||||
|
type AuthProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const auth = useAuth();
|
||||||
|
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthContext() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("AuthProvider is missing");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
30
apps/web/hooks/entry-mutation-context.tsx
Normal file
30
apps/web/hooks/entry-mutation-context.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type EntryMutationContextValue = {
|
||||||
|
mutationVersion: number;
|
||||||
|
notifyEntryMutation: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntryMutationContext = createContext<EntryMutationContextValue | null>(null);
|
||||||
|
|
||||||
|
export function EntryMutationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mutationVersion, setMutationVersion] = useState(0);
|
||||||
|
|
||||||
|
const notifyEntryMutation = useCallback(() => {
|
||||||
|
setMutationVersion(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntryMutationContext.Provider value={{ mutationVersion, notifyEntryMutation }}>
|
||||||
|
{children}
|
||||||
|
</EntryMutationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntryMutation() {
|
||||||
|
const context = useContext(EntryMutationContext);
|
||||||
|
if (!context) throw new Error("useEntryMutation must be used within EntryMutationProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
21
apps/web/hooks/groups-context.tsx
Normal file
21
apps/web/hooks/groups-context.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import useGroups from "@/hooks/use-groups";
|
||||||
|
|
||||||
|
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
|
||||||
|
|
||||||
|
type GroupsProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GroupsProvider({ children }: GroupsProviderProps) {
|
||||||
|
const groups = useGroups();
|
||||||
|
return <GroupsContext.Provider value={groups}>{children}</GroupsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroupsContext() {
|
||||||
|
const ctx = useContext(GroupsContext);
|
||||||
|
if (!ctx) throw new Error("GroupsProvider is missing");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
83
apps/web/hooks/notifications-context.tsx
Normal file
83
apps/web/hooks/notifications-context.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/components/notifications-toaster";
|
||||||
|
|
||||||
|
type NotifyInput = {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
tone?: NotificationTone;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationsContextValue = {
|
||||||
|
notify: (input: NotifyInput) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||||
|
const timersRef = useRef<number[]>([]);
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
setItems(prev => prev.map(item => item.id === id ? { ...item, closing: true } : item));
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
}, 250);
|
||||||
|
timersRef.current.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(input: NotifyInput) {
|
||||||
|
const id = createId();
|
||||||
|
const durationMs = Math.max(1200, input.durationMs ?? 4200);
|
||||||
|
const tone = input.tone ?? "info";
|
||||||
|
|
||||||
|
setItems(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
tone,
|
||||||
|
closing: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fadeMs = 250;
|
||||||
|
const t1 = window.setTimeout(() => {
|
||||||
|
setItems(prev => prev.map(item => item.id === id ? { ...item, closing: true } : item));
|
||||||
|
}, Math.max(0, durationMs - fadeMs));
|
||||||
|
const t2 = window.setTimeout(() => {
|
||||||
|
setItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
}, durationMs);
|
||||||
|
timersRef.current.push(t1, t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
timersRef.current.forEach(timer => window.clearTimeout(timer));
|
||||||
|
timersRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ notify }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<NotificationsToaster items={items} onDismiss={dismiss} />
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationsContext() {
|
||||||
|
const ctx = useContext(NotificationsContext);
|
||||||
|
if (!ctx) throw new Error("NotificationsProvider is missing");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
72
apps/web/hooks/use-auth.ts
Normal file
72
apps/web/hooks/use-auth.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { authLogin, authMe, authRegister, authLogout } from "@/lib/client/auth";
|
||||||
|
import type { User } from "@/lib/shared/types";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type LoginInput = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
remember: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterInput = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useAuth() {
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSession = useCallback(async () => {
|
||||||
|
const res = await authMe();
|
||||||
|
if (isError(res)) return null;
|
||||||
|
return res.data.user as User;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (input: LoginInput) => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authLogin(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return { ok: false, error: result.error.message || "" };
|
||||||
|
}
|
||||||
|
return { ok: true } as { ok: true };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (input: RegisterInput) => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authRegister(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return { ok: false, error: result.error.message || "" };
|
||||||
|
}
|
||||||
|
return { ok: true } as { ok: true };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
const result = await authLogout();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return { ok: false, error: result.error.message || "" };
|
||||||
|
}
|
||||||
|
return { ok: true } as { ok: true };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { checkSession, login, register, logout, error, loading };
|
||||||
|
}
|
||||||
119
apps/web/hooks/use-buckets.ts
Normal file
119
apps/web/hooks/use-buckets.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { bucketsCreate, bucketsDelete, bucketsList, bucketsUpdate } from "@/lib/client/buckets";
|
||||||
|
import type { Bucket } from "@/lib/client/buckets";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type CreateBucketInput = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
iconKey?: string | null;
|
||||||
|
budgetLimitDollars?: number | null;
|
||||||
|
position?: number;
|
||||||
|
tags?: string[];
|
||||||
|
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
windowDays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateBucketInput = CreateBucketInput & { id: number };
|
||||||
|
|
||||||
|
export default function useBuckets(activeGroupId?: number | null) {
|
||||||
|
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setError("");
|
||||||
|
setBuckets([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await bucketsList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setBuckets([]);
|
||||||
|
} else {
|
||||||
|
setBuckets(result.data.buckets || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const createBucket = useCallback(async (input: CreateBucketInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await bucketsCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const updateBucket = useCallback(async (input: UpdateBucketInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await bucketsUpdate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const reorderBuckets = useCallback(async (ordered: Bucket[]) => {
|
||||||
|
setError("");
|
||||||
|
const updates = ordered.map((bucket, index) => bucketsUpdate({
|
||||||
|
id: bucket.id,
|
||||||
|
name: bucket.name,
|
||||||
|
description: bucket.description || undefined,
|
||||||
|
iconKey: bucket.iconKey,
|
||||||
|
budgetLimitDollars: bucket.budgetLimitDollars,
|
||||||
|
position: index,
|
||||||
|
tags: bucket.tags,
|
||||||
|
necessity: bucket.necessity,
|
||||||
|
windowDays: bucket.windowDays
|
||||||
|
}));
|
||||||
|
const results = await Promise.all(updates);
|
||||||
|
const failed = results.find(result => isError(result));
|
||||||
|
if (failed && "error" in failed) {
|
||||||
|
setError(failed.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const deleteBucket = useCallback(async (id: number | string) => {
|
||||||
|
setError("");
|
||||||
|
const result = await bucketsDelete({ id });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buckets,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createBucket,
|
||||||
|
updateBucket,
|
||||||
|
reorderBuckets,
|
||||||
|
deleteBucket,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
124
apps/web/hooks/use-entries.ts
Normal file
124
apps/web/hooks/use-entries.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Entry } from "@/lib/shared/types";
|
||||||
|
import { entriesCreate, entriesDelete, entriesList, entriesUpdate } from "@/lib/client/entries";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type CreateEntryInput = {
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
bucketId?: number | null;
|
||||||
|
isRecurring?: boolean;
|
||||||
|
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
nextRunAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateEntryInput = CreateEntryInput & { id: number };
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareEntriesDesc(a: Entry, b: Entry) {
|
||||||
|
if (a.occurredAt === b.occurredAt) return Number(b.id) - Number(a.id);
|
||||||
|
return a.occurredAt > b.occurredAt ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEntrySorted(entries: Entry[], next: Entry) {
|
||||||
|
const without = entries.filter(entry => Number(entry.id) !== Number(next.id));
|
||||||
|
const merged = [next, ...without];
|
||||||
|
merged.sort(compareEntriesDesc);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useEntries(activeGroupId?: number | null) {
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setError("");
|
||||||
|
setEntries([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await entriesList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setEntries([]);
|
||||||
|
} else {
|
||||||
|
setEntries(result.data.entries || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const createEntry = useCallback(async (input: CreateEntryInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await entriesCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const created = result.data.entry;
|
||||||
|
setEntries(prev => upsertEntrySorted(prev, created));
|
||||||
|
return created;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await entriesUpdate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updated = result.data.entry;
|
||||||
|
setEntries(prev => {
|
||||||
|
return upsertEntrySorted(prev, updated);
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteEntry = useCallback(async (id: number | string) => {
|
||||||
|
setError("");
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
|
let removed: Entry | null = null;
|
||||||
|
const result = await entriesDelete({ id });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setEntries(prev => {
|
||||||
|
const index = prev.findIndex(entry => Number(entry.id) === numericId);
|
||||||
|
if (index < 0) return prev;
|
||||||
|
removed = prev[index];
|
||||||
|
return [...prev.slice(0, index), ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
33
apps/web/hooks/use-group-audit.ts
Normal file
33
apps/web/hooks/use-group-audit.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
import { groupAuditList, type GroupAuditEvent } from "@/lib/client/group-audit";
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useGroupAudit(activeGroupId?: number | null) {
|
||||||
|
const [events, setEvents] = useState<GroupAuditEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setEvents([]);
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await groupAuditList();
|
||||||
|
if (isError(result)) setError(result.error.message || "");
|
||||||
|
else setEvents(result.data.events || []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { events, loading, error, reload: load };
|
||||||
|
}
|
||||||
82
apps/web/hooks/use-group-invites.ts
Normal file
82
apps/web/hooks/use-group-invites.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
import { groupInvitesCreate, groupInvitesDelete, groupInvitesList, groupInvitesRevoke, groupInvitesRevive, type InviteLink, type JoinPolicy } from "@/lib/client/group-invites";
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useGroupInvites(activeGroupId?: number | null) {
|
||||||
|
const [links, setLinks] = useState<InviteLink[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setLinks([]);
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await groupInvitesList();
|
||||||
|
if (isError(result)) setError(result.error.message || "");
|
||||||
|
else setLinks(result.data.links || []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const create = useCallback(async (input: { policy: JoinPolicy; singleUse: boolean; ttlDays: number }) => {
|
||||||
|
const result = await groupInvitesCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return result.data.link;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const revoke = useCallback(async (linkId: number) => {
|
||||||
|
const result = await groupInvitesRevoke({ linkId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const revive = useCallback(async (linkId: number, ttlDays: number) => {
|
||||||
|
const result = await groupInvitesRevive({ linkId, ttlDays });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const remove = useCallback(async (linkId: number) => {
|
||||||
|
const result = await groupInvitesDelete({ linkId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
revive,
|
||||||
|
remove,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
136
apps/web/hooks/use-group-members.ts
Normal file
136
apps/web/hooks/use-group-members.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
import {
|
||||||
|
groupMembersApprove,
|
||||||
|
groupMembersDemote,
|
||||||
|
groupMembersKick,
|
||||||
|
groupMembersLeave,
|
||||||
|
groupMembersList,
|
||||||
|
groupMembersPromote,
|
||||||
|
groupMembersTransferOwner,
|
||||||
|
groupMembersDeny,
|
||||||
|
type GroupMember,
|
||||||
|
type JoinRequest
|
||||||
|
} from "@/lib/client/group-members";
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useGroupMembers(activeGroupId?: number | null) {
|
||||||
|
const [members, setMembers] = useState<GroupMember[]>([]);
|
||||||
|
const [requests, setRequests] = useState<JoinRequest[]>([]);
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setMembers([]);
|
||||||
|
setRequests([]);
|
||||||
|
setCurrentUserId(null);
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await groupMembersList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
} else {
|
||||||
|
setMembers(result.data.members || []);
|
||||||
|
setRequests(result.data.requests || []);
|
||||||
|
setCurrentUserId(result.data.currentUserId || null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const approve = useCallback(async (userId: number, requestId?: number) => {
|
||||||
|
const result = await groupMembersApprove({ userId, requestId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const deny = useCallback(async (userId: number) => {
|
||||||
|
const result = await groupMembersDeny({ userId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const kick = useCallback(async (userId: number) => {
|
||||||
|
const result = await groupMembersKick({ userId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const promote = useCallback(async (userId: number) => {
|
||||||
|
const result = await groupMembersPromote({ userId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const demote = useCallback(async (userId: number) => {
|
||||||
|
const result = await groupMembersDemote({ userId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const transferOwner = useCallback(async (userId: number) => {
|
||||||
|
const result = await groupMembersTransferOwner({ userId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const leave = useCallback(async () => {
|
||||||
|
const result = await groupMembersLeave();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
members,
|
||||||
|
requests,
|
||||||
|
currentUserId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
approve,
|
||||||
|
deny,
|
||||||
|
kick,
|
||||||
|
promote,
|
||||||
|
demote,
|
||||||
|
transferOwner,
|
||||||
|
leave,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
43
apps/web/hooks/use-group-settings.ts
Normal file
43
apps/web/hooks/use-group-settings.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { groupSettingsGet, groupSettingsUpdate } from "@/lib/client/group-settings";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
export default function useGroupSettings(activeGroupId?: number | null) {
|
||||||
|
const [settings, setSettings] = useState({ allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED" });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setSettings({ allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" });
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await groupSettingsGet();
|
||||||
|
if (isError(result)) setError(result.error.message || "");
|
||||||
|
else setSettings(result.data.settings);
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const updateSettings = useCallback(async (allowMemberTagManage: boolean, joinPolicy?: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await groupSettingsUpdate({ allowMemberTagManage, joinPolicy });
|
||||||
|
if (isError(result)) setError(result.error.message || "");
|
||||||
|
else setSettings(result.data.settings);
|
||||||
|
setLoading(false);
|
||||||
|
return isError(result) ? false : true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { settings, loading, error, updateSettings, reload: load };
|
||||||
|
}
|
||||||
100
apps/web/hooks/use-groups.ts
Normal file
100
apps/web/hooks/use-groups.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Group } from "@/lib/shared/types";
|
||||||
|
import { groupsActive, groupsCreate, groupsJoin, groupsList, groupsSetActive } from "@/lib/client/groups";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type CreateGroupInput = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JoinGroupInput = {
|
||||||
|
inviteCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useGroups() {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await groupsList();
|
||||||
|
if (!isError(res)) setGroups(res.data.groups || []);
|
||||||
|
const activeRes = await groupsActive();
|
||||||
|
if (!isError(activeRes)) setActiveGroupId(activeRes.data.active?.id || null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createGroup = useCallback(async (input: CreateGroupInput) => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await groupsCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null as { id: number; name: string; inviteCode?: string } | null;
|
||||||
|
}
|
||||||
|
const group = result.data.group as { id: number; name: string; inviteCode?: string };
|
||||||
|
setGroups(prev => [{ id: group.id, name: group.name, role: "GROUP_ADMIN" }, ...prev]);
|
||||||
|
setActiveGroupId(group.id);
|
||||||
|
await setActiveGroup(group.id);
|
||||||
|
return group;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const joinGroup = useCallback(async (input: JoinGroupInput) => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await groupsJoin(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null as { id: number; name: string } | null;
|
||||||
|
}
|
||||||
|
const group = result.data.group as { id: number; name: string };
|
||||||
|
setGroups(prev => prev.some(g => g.id === group.id) ? prev : [{ id: group.id, name: group.name, role: "MEMBER" }, ...prev]);
|
||||||
|
setActiveGroupId(group.id);
|
||||||
|
await setActiveGroup(group.id);
|
||||||
|
return group;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveGroup = useCallback(async (groupId: number) => {
|
||||||
|
setError("");
|
||||||
|
const result = await groupsSetActive({ groupId });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setActiveGroupId(groupId);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
activeGroupId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createGroup,
|
||||||
|
joinGroup,
|
||||||
|
setActiveGroup,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
64
apps/web/hooks/use-invite-link.ts
Normal file
64
apps/web/hooks/use-invite-link.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
import { inviteLinkAccept, inviteLinkGet, type InviteAcceptResult, type InviteLinkSummary } from "@/lib/client/invite-links";
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useInviteLink(token: string | null) {
|
||||||
|
const [link, setLink] = useState<InviteLinkSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [result, setResult] = useState<InviteAcceptResult | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setLink(null);
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
const response = await inviteLinkGet(token);
|
||||||
|
if (isError(response)) {
|
||||||
|
setError(response.error.message || "");
|
||||||
|
setLink(null);
|
||||||
|
} else {
|
||||||
|
setLink(response.data.link);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const accept = useCallback(async () => {
|
||||||
|
if (!token) return null;
|
||||||
|
setAccepting(true);
|
||||||
|
setError("");
|
||||||
|
const response = await inviteLinkAccept(token);
|
||||||
|
if (isError(response)) {
|
||||||
|
setError(response.error.message || "");
|
||||||
|
setAccepting(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setResult(response.data.result);
|
||||||
|
setAccepting(false);
|
||||||
|
return response.data.result;
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
loading,
|
||||||
|
accepting,
|
||||||
|
error,
|
||||||
|
result,
|
||||||
|
accept,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
99
apps/web/hooks/use-recurring-entries.ts
Normal file
99
apps/web/hooks/use-recurring-entries.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Entry } from "@/lib/shared/types";
|
||||||
|
import { recurringEntriesCreate, recurringEntriesDelete, recurringEntriesList, recurringEntriesUpdate } from "@/lib/client/recurring-entries";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type CreateRecurringEntryInput = {
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
occurredAt: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
bucketId?: number | null;
|
||||||
|
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
nextRunAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateRecurringEntryInput = CreateRecurringEntryInput & { id: number };
|
||||||
|
|
||||||
|
export default function useRecurringEntries(activeGroupId?: number | null) {
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setError("");
|
||||||
|
setEntries([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await recurringEntriesList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setEntries([]);
|
||||||
|
} else {
|
||||||
|
setEntries(result.data.entries || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const createEntry = useCallback(async (input: CreateRecurringEntryInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await recurringEntriesCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const updateEntry = useCallback(async (input: UpdateRecurringEntryInput) => {
|
||||||
|
setError("");
|
||||||
|
const result = await recurringEntriesUpdate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const deleteEntry = useCallback(async (id: number | string) => {
|
||||||
|
setError("");
|
||||||
|
const result = await recurringEntriesDelete({ id });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
59
apps/web/hooks/use-tags.ts
Normal file
59
apps/web/hooks/use-tags.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { tagsCreate, tagsDelete, tagsList } from "@/lib/client/tags";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
export default function useTags(activeGroupId?: number | null) {
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setTags([]);
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await tagsList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setTags([]);
|
||||||
|
} else {
|
||||||
|
setTags(result.data.tags || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const addTags = useCallback(async (nextTags: string[]) => {
|
||||||
|
setError("");
|
||||||
|
const result = await tagsCreate({ tags: nextTags });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const removeTag = useCallback(async (tag: string) => {
|
||||||
|
setError("");
|
||||||
|
const result = await tagsDelete({ name: tag });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { tags, loading, error, addTags, removeTag, reload: load };
|
||||||
|
}
|
||||||
1
apps/web/lib/auth.ts
Normal file
1
apps/web/lib/auth.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user