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